@drawnagency/primitives 0.1.118 → 0.1.119

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 (258) 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-CEFFP3CH.js → chunk-G6O3TYPJ.js} +297 -83
  4. package/dist/{chunk-4NSOKEA7.js → chunk-HFHM4MNK.js} +12 -7
  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-YKKGVSJH.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/PagesModal.d.ts +0 -2
  19. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  20. package/dist/components/editor/index.js +15 -19
  21. package/dist/components/primitives/index.js +11 -15
  22. package/dist/components/sections/brand-guide.js +10 -13
  23. package/dist/components/sections/register-schemas.js +16 -21
  24. package/dist/components/sections/register.js +16 -21
  25. package/dist/components/shell/AudienceChips.d.ts +1 -3
  26. package/dist/components/shell/AudienceChips.d.ts.map +1 -1
  27. package/dist/components/shell/EditorShell.d.ts +0 -34
  28. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  29. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  30. package/dist/hooks/useSortableNavRow.d.ts +1 -1
  31. package/dist/hooks/useSortableRow.d.ts +1 -1
  32. package/dist/index.js +83 -90
  33. package/dist/lib/index-ops.js +5 -6
  34. package/dist/lib/index.js +27 -32
  35. package/dist/lib/pages.d.ts +0 -2
  36. package/dist/lib/pages.d.ts.map +1 -1
  37. package/dist/lib/sidecars.js +2 -2
  38. package/dist/lib/site-validation.js +13 -14
  39. package/dist/lib/storage-tier.js +4 -4
  40. package/dist/schemas/index.js +42 -45
  41. package/dist/schemas/site-config.d.ts +1 -277
  42. package/dist/schemas/site-config.d.ts.map +1 -1
  43. package/package.json +2 -42
  44. package/src/components/chat/ChatComposer.tsx +0 -4
  45. package/src/components/chat/ChatPanel.tsx +2 -3
  46. package/src/components/chat/markdown.ts +2 -3
  47. package/src/components/editor/PagesModal.tsx +1 -15
  48. package/src/components/editor/StatusIndicator.tsx +4 -4
  49. package/src/components/shell/AudienceChips.tsx +1 -3
  50. package/src/components/shell/EditorShell.tsx +5 -113
  51. package/src/components/shell/SiteSettingsModal.tsx +0 -11
  52. package/src/lib/pages.ts +0 -22
  53. package/src/schemas/index.ts +0 -4
  54. package/src/schemas/site-config.ts +4 -32
  55. package/dist/chunk-4TZJZU2T.js +0 -92
  56. package/dist/chunk-DIMDRBFK.js +0 -11
  57. package/dist/chunk-FV7JQRCN.js +0 -94
  58. package/dist/chunk-IIDKW7AV.js +0 -41
  59. package/dist/chunk-JCDZAFX2.js +0 -224
  60. package/dist/chunk-MM7KZS5C.js +0 -30
  61. package/dist/chunk-QQ3S3P5P.js +0 -38
  62. package/dist/chunk-TEOAKGZA.js +0 -784
  63. package/dist/chunk-VAYZJKVR.js +0 -209
  64. package/dist/components/shell/DocInspector.d.ts +0 -18
  65. package/dist/components/shell/DocInspector.d.ts.map +0 -1
  66. package/dist/components/shell/DocumentEditorOverlay.d.ts +0 -29
  67. package/dist/components/shell/DocumentEditorOverlay.d.ts.map +0 -1
  68. package/dist/components/shell/DocumentPresentOverlay.d.ts +0 -30
  69. package/dist/components/shell/DocumentPresentOverlay.d.ts.map +0 -1
  70. package/dist/components/shell/SiteSettingsDecks.d.ts +0 -13
  71. package/dist/components/shell/SiteSettingsDecks.d.ts.map +0 -1
  72. package/dist/deck/DeckComponentView.d.ts +0 -13
  73. package/dist/deck/DeckComponentView.d.ts.map +0 -1
  74. package/dist/deck/DeckPlayer.d.ts +0 -26
  75. package/dist/deck/DeckPlayer.d.ts.map +0 -1
  76. package/dist/deck/DeckSlideView.d.ts +0 -17
  77. package/dist/deck/DeckSlideView.d.ts.map +0 -1
  78. package/dist/deck/DeckStage.d.ts +0 -16
  79. package/dist/deck/DeckStage.d.ts.map +0 -1
  80. package/dist/deck/components/DeckImage.d.ts +0 -14
  81. package/dist/deck/components/DeckImage.d.ts.map +0 -1
  82. package/dist/deck/components/DeckImageGrid.d.ts +0 -27
  83. package/dist/deck/components/DeckImageGrid.d.ts.map +0 -1
  84. package/dist/deck/components/DeckRichText.d.ts +0 -22
  85. package/dist/deck/components/DeckRichText.d.ts.map +0 -1
  86. package/dist/deck/components/DeckVideo.d.ts +0 -15
  87. package/dist/deck/components/DeckVideo.d.ts.map +0 -1
  88. package/dist/deck/editing.d.ts +0 -50
  89. package/dist/deck/editing.d.ts.map +0 -1
  90. package/dist/deck/flip.d.ts +0 -11
  91. package/dist/deck/flip.d.ts.map +0 -1
  92. package/dist/deck/index.d.ts +0 -24
  93. package/dist/deck/index.d.ts.map +0 -1
  94. package/dist/deck/index.js +0 -51
  95. package/dist/deck/layouts/CenteredTextLayout.d.ts +0 -12
  96. package/dist/deck/layouts/CenteredTextLayout.d.ts.map +0 -1
  97. package/dist/deck/layouts/ColumnSplitLayout.d.ts +0 -14
  98. package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +0 -1
  99. package/dist/deck/layouts/FullBleedImageLayout.d.ts +0 -14
  100. package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +0 -1
  101. package/dist/deck/media.d.ts +0 -33
  102. package/dist/deck/media.d.ts.map +0 -1
  103. package/dist/deck/tokens.d.ts +0 -22
  104. package/dist/deck/tokens.d.ts.map +0 -1
  105. package/dist/deck/useStageScale.d.ts +0 -10
  106. package/dist/deck/useStageScale.d.ts.map +0 -1
  107. package/dist/deck-builder/DeckBuilderApp.d.ts +0 -30
  108. package/dist/deck-builder/DeckBuilderApp.d.ts.map +0 -1
  109. package/dist/deck-builder/DeckBuilderTopBar.d.ts +0 -24
  110. package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +0 -1
  111. package/dist/deck-builder/DeckChatPanel.d.ts +0 -45
  112. package/dist/deck-builder/DeckChatPanel.d.ts.map +0 -1
  113. package/dist/deck-builder/DeckEditor.d.ts +0 -37
  114. package/dist/deck-builder/DeckEditor.d.ts.map +0 -1
  115. package/dist/deck-builder/DeckLibrary.d.ts +0 -22
  116. package/dist/deck-builder/DeckLibrary.d.ts.map +0 -1
  117. package/dist/deck-builder/DeckSlideSurface.d.ts +0 -31
  118. package/dist/deck-builder/DeckSlideSurface.d.ts.map +0 -1
  119. package/dist/deck-builder/deck-api.d.ts +0 -76
  120. package/dist/deck-builder/deck-api.d.ts.map +0 -1
  121. package/dist/deck-builder/deck-chat-client.d.ts +0 -44
  122. package/dist/deck-builder/deck-chat-client.d.ts.map +0 -1
  123. package/dist/deck-builder/deck-chat-history.d.ts +0 -10
  124. package/dist/deck-builder/deck-chat-history.d.ts.map +0 -1
  125. package/dist/deck-builder/index.d.ts +0 -13
  126. package/dist/deck-builder/index.d.ts.map +0 -1
  127. package/dist/deck-builder/index.js +0 -1498
  128. package/dist/deck-builder/starter-deck.d.ts +0 -23
  129. package/dist/deck-builder/starter-deck.d.ts.map +0 -1
  130. package/dist/deck-builder/types.d.ts +0 -29
  131. package/dist/deck-builder/types.d.ts.map +0 -1
  132. package/dist/deck-builder/useSortableSlide.d.ts +0 -29
  133. package/dist/deck-builder/useSortableSlide.d.ts.map +0 -1
  134. package/dist/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  135. package/dist/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  136. package/dist/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  137. package/dist/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  138. package/dist/document/components/DocCoverPage.d.ts +0 -11
  139. package/dist/document/components/DocCoverPage.d.ts.map +0 -1
  140. package/dist/document/components/DocPage.d.ts +0 -10
  141. package/dist/document/components/DocPage.d.ts.map +0 -1
  142. package/dist/document/components/DocRail.d.ts +0 -28
  143. package/dist/document/components/DocRail.d.ts.map +0 -1
  144. package/dist/document/components/DocSectionPage.d.ts +0 -12
  145. package/dist/document/components/DocSectionPage.d.ts.map +0 -1
  146. package/dist/document/components/DocViewer.d.ts +0 -21
  147. package/dist/document/components/DocViewer.d.ts.map +0 -1
  148. package/dist/document/components/Wordmark.d.ts +0 -4
  149. package/dist/document/components/Wordmark.d.ts.map +0 -1
  150. package/dist/document/components/measure.d.ts +0 -7
  151. package/dist/document/components/measure.d.ts.map +0 -1
  152. package/dist/document/components/useDocPages.d.ts +0 -12
  153. package/dist/document/components/useDocPages.d.ts.map +0 -1
  154. package/dist/document/engine/blocks.d.ts +0 -2
  155. package/dist/document/engine/blocks.d.ts.map +0 -1
  156. package/dist/document/engine/harvest.d.ts +0 -3
  157. package/dist/document/engine/harvest.d.ts.map +0 -1
  158. package/dist/document/engine/masonry.d.ts +0 -26
  159. package/dist/document/engine/masonry.d.ts.map +0 -1
  160. package/dist/document/engine/paginate.d.ts +0 -43
  161. package/dist/document/engine/paginate.d.ts.map +0 -1
  162. package/dist/document/engine/settings.d.ts +0 -91
  163. package/dist/document/engine/settings.d.ts.map +0 -1
  164. package/dist/document/engine/types.d.ts +0 -44
  165. package/dist/document/engine/types.d.ts.map +0 -1
  166. package/dist/document/index.d.ts +0 -13
  167. package/dist/document/index.d.ts.map +0 -1
  168. package/dist/document/index.js +0 -1030
  169. package/dist/document/styles/document.css +0 -217
  170. package/dist/lib/deck-access.d.ts +0 -86
  171. package/dist/lib/deck-access.d.ts.map +0 -1
  172. package/dist/lib/deck-access.js +0 -58
  173. package/dist/lib/deck-dexie.d.ts +0 -88
  174. package/dist/lib/deck-dexie.d.ts.map +0 -1
  175. package/dist/lib/deck-dexie.js +0 -22
  176. package/dist/lib/deck-edit.d.ts +0 -67
  177. package/dist/lib/deck-edit.d.ts.map +0 -1
  178. package/dist/lib/deck-media-assets.d.ts +0 -27
  179. package/dist/lib/deck-media-assets.d.ts.map +0 -1
  180. package/dist/lib/deck-media-assets.js +0 -25
  181. package/dist/lib/deck-media-refs.d.ts +0 -35
  182. package/dist/lib/deck-media-refs.d.ts.map +0 -1
  183. package/dist/lib/deck-media-refs.js +0 -36
  184. package/dist/lib/deck-tool.d.ts +0 -25
  185. package/dist/lib/deck-tool.d.ts.map +0 -1
  186. package/dist/lib/deck-tool.js +0 -58
  187. package/dist/lib/document-input.d.ts +0 -25
  188. package/dist/lib/document-input.d.ts.map +0 -1
  189. package/dist/schemas/deck-meta.d.ts +0 -85
  190. package/dist/schemas/deck-meta.d.ts.map +0 -1
  191. package/dist/schemas/deck-meta.js +0 -64
  192. package/dist/schemas/deck.d.ts +0 -954
  193. package/dist/schemas/deck.d.ts.map +0 -1
  194. package/dist/schemas/deck.js +0 -65
  195. package/src/components/shell/DocInspector.tsx +0 -314
  196. package/src/components/shell/DocumentEditorOverlay.tsx +0 -179
  197. package/src/components/shell/DocumentPresentOverlay.tsx +0 -87
  198. package/src/components/shell/SiteSettingsDecks.tsx +0 -41
  199. package/src/deck/DeckComponentView.tsx +0 -28
  200. package/src/deck/DeckPlayer.tsx +0 -166
  201. package/src/deck/DeckSlideView.tsx +0 -38
  202. package/src/deck/DeckStage.tsx +0 -39
  203. package/src/deck/components/DeckImage.tsx +0 -38
  204. package/src/deck/components/DeckImageGrid.tsx +0 -254
  205. package/src/deck/components/DeckRichText.tsx +0 -111
  206. package/src/deck/components/DeckVideo.tsx +0 -81
  207. package/src/deck/editing.tsx +0 -201
  208. package/src/deck/flip.ts +0 -70
  209. package/src/deck/index.ts +0 -30
  210. package/src/deck/layouts/CenteredTextLayout.tsx +0 -29
  211. package/src/deck/layouts/ColumnSplitLayout.tsx +0 -69
  212. package/src/deck/layouts/FullBleedImageLayout.tsx +0 -58
  213. package/src/deck/media.tsx +0 -46
  214. package/src/deck/tokens.ts +0 -57
  215. package/src/deck/useStageScale.ts +0 -44
  216. package/src/deck-builder/DeckBuilderApp.tsx +0 -572
  217. package/src/deck-builder/DeckBuilderTopBar.tsx +0 -62
  218. package/src/deck-builder/DeckChatPanel.tsx +0 -334
  219. package/src/deck-builder/DeckEditor.tsx +0 -258
  220. package/src/deck-builder/DeckLibrary.tsx +0 -195
  221. package/src/deck-builder/DeckSlideSurface.tsx +0 -55
  222. package/src/deck-builder/deck-api.ts +0 -177
  223. package/src/deck-builder/deck-chat-client.ts +0 -129
  224. package/src/deck-builder/deck-chat-history.ts +0 -62
  225. package/src/deck-builder/index.ts +0 -12
  226. package/src/deck-builder/starter-deck.ts +0 -86
  227. package/src/deck-builder/types.ts +0 -24
  228. package/src/deck-builder/useSortableSlide.ts +0 -102
  229. package/src/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  230. package/src/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  231. package/src/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  232. package/src/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  233. package/src/document/components/DocCoverPage.tsx +0 -45
  234. package/src/document/components/DocPage.tsx +0 -40
  235. package/src/document/components/DocRail.tsx +0 -189
  236. package/src/document/components/DocSectionPage.tsx +0 -48
  237. package/src/document/components/DocViewer.tsx +0 -313
  238. package/src/document/components/Wordmark.tsx +0 -18
  239. package/src/document/components/measure.tsx +0 -133
  240. package/src/document/components/useDocPages.ts +0 -95
  241. package/src/document/engine/blocks.ts +0 -116
  242. package/src/document/engine/harvest.ts +0 -95
  243. package/src/document/engine/masonry.ts +0 -158
  244. package/src/document/engine/paginate.ts +0 -232
  245. package/src/document/engine/settings.ts +0 -125
  246. package/src/document/engine/types.ts +0 -64
  247. package/src/document/index.ts +0 -18
  248. package/src/document/styles/document.css +0 -217
  249. package/src/lib/deck-access.ts +0 -132
  250. package/src/lib/deck-dexie.ts +0 -190
  251. package/src/lib/deck-edit.ts +0 -196
  252. package/src/lib/deck-media-assets.ts +0 -57
  253. package/src/lib/deck-media-refs.ts +0 -67
  254. package/src/lib/deck-tool.ts +0 -95
  255. package/src/lib/document-input.ts +0 -57
  256. package/src/schemas/deck-meta.ts +0 -88
  257. package/src/schemas/deck.ts +0 -478
  258. package/dist/{chunk-DJABADOO.js → chunk-AJ2QOAZI.js} +3 -3
@@ -1,572 +0,0 @@
1
- import { MessageSquareIcon, PlusIcon } from "lucide-react";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { Button } from "../components/shared/Button";
4
- import { DeckChatPanel } from "./DeckChatPanel";
5
- import { clearDeckChat } from "./deck-chat-history";
6
- import {
7
- deleteDeckDraft,
8
- getDeckDraft,
9
- hasUnpublishedChanges,
10
- initDeckDraftStore,
11
- listDeckDrafts,
12
- markDeckDraftPublished,
13
- putDeckDraft,
14
- recordDeckPublish,
15
- type DeckDraftRow,
16
- } from "../lib/deck-dexie";
17
- import {
18
- applyDeckTextEdit,
19
- duplicateDeckSlide,
20
- moveDeckSlide,
21
- removeDeckSlide,
22
- type DeckEditResult,
23
- type DeckTextPath,
24
- } from "../lib/deck-edit";
25
- import type { Deck, DeckMediaAsset } from "../schemas/deck";
26
- import {
27
- deletePublishedDeck,
28
- listPublishedDecks,
29
- publishDeck,
30
- readPublishedDeck,
31
- type DeckApiFailure,
32
- type PublishedDeckSummary,
33
- } from "./deck-api";
34
- import { DeckBuilderTopBar } from "./DeckBuilderTopBar";
35
- import { DeckEditor } from "./DeckEditor";
36
- import { DeckLibrary } from "./DeckLibrary";
37
- import { newDeckId, starterDeck } from "./starter-deck";
38
- import type { DeckGridEntry } from "./types";
39
-
40
- /**
41
- * The Deck Builder shell (#34).
42
- *
43
- * Builder mode is its own ROUTE rather than an overlay on a viewer page, which
44
- * is what makes "the sidebar drops out for a top bar" and "leaving restores
45
- * portal chrome" trivially true — and what keeps every byte of the deck runtime
46
- * off ordinary viewer pages, where the entry point is a plain server-rendered
47
- * `<a>` FAB. `/present` is the same shape for the same reasons.
48
- *
49
- * The working deck is a Deck Draft in IndexedDB (ADR 0002): the builder reads
50
- * the decks branch to list and to reopen, and writes to it only when the author
51
- * presses Publish.
52
- */
53
- interface Props {
54
- /** Keys the draft database, so two portals in one browser never collide. */
55
- siteId: string;
56
- /** The other half of that key: a `deckDraftScope` digest of the caller's
57
- * identity, so two people sharing a browser profile never share drafts. */
58
- draftScope: string;
59
- /** Pre-resolved portal media for the runtime's resolver seam. */
60
- mediaAssets: Record<string, DeckMediaAsset>;
61
- /** Where "Leave builder" goes — the page the FAB was pressed on. */
62
- exitHref: string;
63
- /** Server-decided: editor, or a member of a deck-enabled audience. */
64
- canCreate: boolean;
65
- }
66
-
67
- type Notice = { tone: "error" | "info"; text: string; details?: string[] } | null;
68
-
69
- /** A pending 409: the author has to choose, so it cannot be a toast. */
70
- type Conflict = { deckId: string; message: string } | null;
71
-
72
- export function DeckBuilderApp({ siteId, draftScope, mediaAssets, exitHref, canCreate }: Props) {
73
- const [drafts, setDrafts] = useState<DeckDraftRow[]>([]);
74
- const [published, setPublished] = useState<PublishedDeckSummary[]>([]);
75
- const [openDeckId, setOpenDeckId] = useState<string | null>(null);
76
- const [slide, setSlide] = useState(0);
77
- const [loading, setLoading] = useState(true);
78
- const [busy, setBusy] = useState(false);
79
- const [notice, setNotice] = useState<Notice>(null);
80
- const [conflict, setConflict] = useState<Conflict>(null);
81
- // The assistant is the primary authoring surface (#35), so it opens with the
82
- // deck rather than waiting to be asked for.
83
- const [chatOpen, setChatOpen] = useState(true);
84
- // A chat turn is a writer to the Deck Draft. Publish locks while one is in
85
- // flight — the same reason #39's inline editing will lock against it.
86
- const [chatBusy, setChatBusy] = useState(false);
87
- // Dexie writes are the source of truth; this only stops a resolved promise
88
- // from setting state after the island is gone.
89
- const alive = useRef(true);
90
- /**
91
- * The last deck this component wrote, held synchronously so a second edit
92
- * committed inside the first one's Dexie round-trip builds on it rather than
93
- * on the stale `openRow.deck`.
94
- *
95
- * It carries its own deck id and `currentDeck` refuses a mismatch, which is
96
- * what makes it SELF-INVALIDATING rather than dependent on every writer
97
- * remembering to clear it. That is not belt-and-braces: a chat turn started on
98
- * deck A can resolve after the author has opened deck B — the panel unmounts
99
- * without aborting the stream — and an unguarded ref then hands deck A to
100
- * every subsequent edit while the stage renders B, writing those edits into
101
- * A's row where they are never seen again.
102
- */
103
- const editBase = useRef<{ deckId: string; deck: Deck } | null>(null);
104
-
105
- useEffect(() => {
106
- alive.current = true;
107
- return () => {
108
- alive.current = false;
109
- };
110
- }, []);
111
-
112
- const refreshDrafts = useCallback(async () => {
113
- const rows = await listDeckDrafts();
114
- if (alive.current) setDrafts(rows);
115
- }, []);
116
-
117
- useEffect(() => {
118
- initDeckDraftStore(siteId, draftScope);
119
- void (async () => {
120
- await refreshDrafts();
121
- const listed = await listPublishedDecks();
122
- if (!alive.current) return;
123
- // A failed listing is not a failed builder: the drafts on this device are
124
- // still openable, and saying so beats an empty grid that looks like data
125
- // loss. 401 is the exception — that is a session to renew, not a blip.
126
- if (listed.ok) setPublished(listed.value);
127
- else setNotice(noticeFor(listed, "Could not list your published decks."));
128
- setLoading(false);
129
- })();
130
- }, [siteId, draftScope, refreshDrafts]);
131
-
132
- // Belt to the id-tag's braces: drop a base the open deck no longer has any
133
- // use for, so a closed deck's body is not held alive for the session.
134
- useEffect(() => {
135
- editBase.current = null;
136
- }, [openDeckId]);
137
-
138
- const entries = useMemo(() => buildEntries(drafts, published), [drafts, published]);
139
- const openRow = openDeckId === null ? null : (drafts.find((row) => row.deckId === openDeckId) ?? null);
140
-
141
- const openDeck = useCallback(
142
- async (id: string) => {
143
- setNotice(null);
144
- setSlide(0);
145
- // Already staged: open the draft. Never re-read — the draft is the newer
146
- // of the two by construction, and clobbering it with the published body
147
- // is precisely the data loss Dexie staging exists to prevent.
148
- if (await getDeckDraft(id)) {
149
- if (alive.current) setOpenDeckId(id);
150
- return;
151
- }
152
- setBusy(true);
153
- const read = await readPublishedDeck(id);
154
- if (!alive.current) return;
155
- setBusy(false);
156
- if (!read.ok) {
157
- setNotice(noticeFor(read, "Could not open that deck."));
158
- return;
159
- }
160
- // The sidecar's own timestamp, not "now": opening a deck to look at it
161
- // must not sort it to the top of the grid.
162
- await markDeckDraftPublished(read.value.deck, read.value.version, read.value.meta?.updatedAt);
163
- await refreshDrafts();
164
- if (alive.current) setOpenDeckId(id);
165
- },
166
- [refreshDrafts],
167
- );
168
-
169
- const createDeck = useCallback(async () => {
170
- setNotice(null);
171
- const title = `Untitled deck ${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}`;
172
- const deck = starterDeck(newDeckId(title), title);
173
- await putDeckDraft(deck);
174
- await refreshDrafts();
175
- if (!alive.current) return;
176
- setSlide(0);
177
- setOpenDeckId(deck.id);
178
- }, [refreshDrafts]);
179
-
180
- /**
181
- * A validated deck from the assistant. LOCAL ONLY: it lands in the Deck Draft
182
- * exactly like any other edit, and publishing stays the author's explicit act
183
- * (#34) — the authoring loop performs no server writes at all (ADR 0002).
184
- */
185
- const applyDeck = useCallback(
186
- async (next: Deck) => {
187
- // A turn is a writer too, so it owns the edit base from here on — tagged
188
- // with its own deck id, because a turn can resolve after the author has
189
- // moved to a different deck (see the ref's own comment).
190
- editBase.current = { deckId: next.id, deck: next };
191
- await putDeckDraft(next);
192
- await refreshDrafts();
193
- if (!alive.current) return;
194
- // A turn can shorten the deck out from under the filmstrip selection.
195
- setSlide((current) => Math.min(current, next.slides.length - 1));
196
- },
197
- [refreshDrafts],
198
- );
199
-
200
- /**
201
- * Stage the result of a direct-manipulation edit (#39).
202
- *
203
- * The ops in `lib/deck-edit` end in `parseDeck`, so a refusal here is the
204
- * schema's own message — an over-long heading, a deleted last slide, a
205
- * duplicate past the slide cap. Nothing invalid is ever staged, and a refused
206
- * edit leaves the draft exactly as it was.
207
- */
208
- const stageEdit = useCallback(
209
- async (result: DeckEditResult, focus?: (deck: Deck) => number) => {
210
- if (!result.ok) {
211
- setNotice({ tone: "error", text: "That change did not fit the deck's rules.", details: result.errors });
212
- return;
213
- }
214
- setNotice(null);
215
- // Synchronously, BEFORE the await: two commits can land inside one Dexie
216
- // round-trip (blurring one field is what activates the next), and until
217
- // `refreshDrafts` returns, `openRow.deck` is still the pre-edit deck. The
218
- // second edit would then be computed against it and overwrite the first.
219
- editBase.current = { deckId: result.deck.id, deck: result.deck };
220
- await putDeckDraft(result.deck);
221
- await refreshDrafts();
222
- if (!alive.current) return;
223
- const last = result.deck.slides.length - 1;
224
- setSlide((current) => Math.max(0, Math.min(focus ? focus(result.deck) : current, last)));
225
- },
226
- [refreshDrafts],
227
- );
228
-
229
- /**
230
- * The freshest staged deck. Read through a callback rather than computed at
231
- * render: `editBase` is written synchronously by `stageEdit` and re-rendering
232
- * is exactly what has not happened yet at the moment the next edit commits.
233
- */
234
- const currentDeck = useCallback((): Deck | null => {
235
- if (openRow === null) return null;
236
- const staged = editBase.current;
237
- return staged !== null && staged.deckId === openRow.deckId ? staged.deck : openRow.deck;
238
- }, [openRow]);
239
-
240
- /**
241
- * The stage's inline text commits. `slide` is the selection as of this render
242
- * — the same one the stage is drawing, which is what the path addresses.
243
- */
244
- const editText = useCallback(
245
- (path: DeckTextPath, text: string) => {
246
- const deck = currentDeck();
247
- if (!deck || chatBusy) return;
248
- const index = Math.min(slide, deck.slides.length - 1);
249
- void stageEdit(applyDeckTextEdit(deck, index, path, text));
250
- },
251
- [currentDeck, slide, chatBusy, stageEdit],
252
- );
253
-
254
- const moveSlide = useCallback(
255
- (from: number, to: number) => {
256
- const deck = currentDeck();
257
- if (!deck || chatBusy) return;
258
- // Follow the slide that moved, not the position it left.
259
- void stageEdit(moveDeckSlide(deck, from, to), () => to);
260
- },
261
- [currentDeck, chatBusy, stageEdit],
262
- );
263
-
264
- const duplicateSlide = useCallback(
265
- (index: number) => {
266
- const deck = currentDeck();
267
- if (!deck || chatBusy) return;
268
- // Land on the copy — it is the thing the author is about to change.
269
- void stageEdit(duplicateDeckSlide(deck, index), () => index + 1);
270
- },
271
- [currentDeck, chatBusy, stageEdit],
272
- );
273
-
274
- const deleteSlide = useCallback(
275
- (index: number) => {
276
- const deck = currentDeck();
277
- if (!deck || chatBusy) return;
278
- void stageEdit(removeDeckSlide(deck, index), () => index - 1);
279
- },
280
- [currentDeck, chatBusy, stageEdit],
281
- );
282
-
283
- const removeDeck = useCallback(
284
- async (id: string) => {
285
- setNotice(null);
286
- setBusy(true);
287
- // Server first: a local-only delete that fails upstream would leave a
288
- // published deck this browser can no longer see, let alone remove.
289
- if (published.some((p) => p.id === id)) {
290
- const deleted = await deletePublishedDeck(id);
291
- if (!alive.current) return;
292
- if (!deleted.ok) {
293
- setBusy(false);
294
- setNotice(noticeFor(deleted, "Could not delete that deck."));
295
- return;
296
- }
297
- setPublished((prev) => prev.filter((p) => p.id !== id));
298
- }
299
- await deleteDeckDraft(id);
300
- // The conversation is about a deck that no longer exists — and a new deck
301
- // could reuse the id, which would silently inherit it.
302
- clearDeckChat(draftScope, id);
303
- await refreshDrafts();
304
- if (!alive.current) return;
305
- setBusy(false);
306
- if (openDeckId === id) setOpenDeckId(null);
307
- },
308
- [published, openDeckId, refreshDrafts, draftScope],
309
- );
310
-
311
- const publish = useCallback(async () => {
312
- if (!openRow) return;
313
- setNotice(null);
314
- setConflict(null);
315
- setBusy(true);
316
- // `currentDeck()`, not `openRow.deck`: clicking Publish while a text field is
317
- // open BLURS it first, so the commit and the click land inside one Dexie
318
- // round-trip and the row is still the pre-edit deck at this line. Publishing
319
- // that would send a deck the author had just changed on screen.
320
- const publishing = currentDeck() ?? openRow.deck;
321
- const result = await publishDeck(publishing, openRow.baseVersion);
322
- if (!alive.current) return;
323
- setBusy(false);
324
- if (!result.ok) {
325
- if (result.kind === "conflict") {
326
- setConflict({ deckId: openRow.deckId, message: result.message });
327
- return;
328
- }
329
- setNotice(noticeFor(result, "Could not publish this deck."));
330
- return;
331
- }
332
- // The durable fix #35 deferred to #39: re-read the row and write back only
333
- // the publish BASELINE, leaving `deck` as whatever is current by now. An
334
- // inline edit committed during the round-trip therefore survives, and
335
- // `hasUnpublishedChanges` reports it — where the old pre-request snapshot
336
- // would have discarded it silently.
337
- //
338
- // The chat panel stays locked during a publish anyway (`disabled={busy}`),
339
- // and it still carries weight: `recordDeckPublish` makes an INLINE edit
340
- // during the round-trip safe, but a whole-deck turn landing inside a publish
341
- // would still commit whichever half of the deck Dexie held at that instant.
342
- await recordDeckPublish(openRow.deckId, publishing, result.value.version);
343
- await refreshDrafts();
344
- if (!alive.current) return;
345
- setPublished((prev) => {
346
- const next = prev.filter((p) => p.id !== openRow.deckId);
347
- next.push({ id: openRow.deckId, version: result.value.version, meta: result.value.meta });
348
- return next;
349
- });
350
- setNotice({ tone: "info", text: "Published." });
351
- }, [openRow, currentDeck, refreshDrafts]);
352
-
353
- /**
354
- * The MVP's answer to a 409: RELOAD-NEWER, and only on request.
355
- *
356
- * Nothing is merged and nothing is overwritten silently — the author is told
357
- * the deck moved and chooses between taking the newer copy (their staged
358
- * edits are dropped, which is why the button says so) and keeping theirs
359
- * (nothing is written; the draft stays exactly as it was). A real merge needs
360
- * the revision history #35's authoring loop will produce, and guessing at one
361
- * now would be a way to lose work quietly.
362
- */
363
- const reloadPublished = useCallback(async () => {
364
- if (!conflict) return;
365
- setBusy(true);
366
- const read = await readPublishedDeck(conflict.deckId);
367
- if (!alive.current) return;
368
- setBusy(false);
369
- if (!read.ok) {
370
- setNotice(noticeFor(read, "Could not reload that deck."));
371
- return;
372
- }
373
- // The deck is being REPLACED, so the staged edit base has to go with it —
374
- // the open deck id does not change here, so nothing else would drop it, and
375
- // keeping it would let the next inline edit rebuild the copy just discarded.
376
- editBase.current = null;
377
- await markDeckDraftPublished(read.value.deck, read.value.version, read.value.meta?.updatedAt);
378
- await refreshDrafts();
379
- if (!alive.current) return;
380
- setConflict(null);
381
- setSlide(0);
382
- setNotice({ tone: "info", text: "Reloaded the published deck. Your staged edits were replaced." });
383
- }, [conflict, refreshDrafts]);
384
-
385
- const dirty = openRow ? hasUnpublishedChanges(openRow) : false;
386
-
387
- return (
388
- <div data-deck-builder-app="" className="flex h-screen min-h-0 flex-col bg-base font-body text-base-contrast">
389
- <DeckBuilderTopBar
390
- exitHref={exitHref}
391
- onBackToLibrary={openDeckId === null ? undefined : () => setOpenDeckId(null)}
392
- primaryAction={
393
- openRow ? (
394
- // Locked during a turn: the deck is being rewritten, so publishing
395
- // now would commit whichever half of it Dexie held at that instant.
396
- <Button variant="brand" onClick={publish} disabled={busy || chatBusy || !dirty}>
397
- {dirty ? "Publish deck" : "Published"}
398
- </Button>
399
- ) : canCreate ? (
400
- <Button variant="brand" onClick={createDeck} disabled={busy}>
401
- <PlusIcon size={14} className="mr-1.5" aria-hidden="true" />
402
- New deck
403
- </Button>
404
- ) : null
405
- }
406
- status={
407
- openRow
408
- ? `${dirty ? "Draft · staged on this device" : "Published"} · ${openRow.deck.slides.length} slides`
409
- : loading
410
- ? "Loading your decks…"
411
- : `${entries.length} ${entries.length === 1 ? "deck" : "decks"}`
412
- }
413
- />
414
-
415
- {/* The builder body: content pane left, assistant docked right. The pane
416
- scrolls; the chat column is IN FLOW rather than fixed (unlike
417
- ChatPanel's docked mode), because the builder owns the whole viewport
418
- and has no centered band for a fixed panel to shift out from under. */}
419
- <div className="flex min-h-0 flex-1">
420
- <div className="min-h-0 flex-1 overflow-y-auto">
421
- {conflict && (
422
- <div role="alert" className="mx-auto flex max-w-screen-xl flex-wrap items-center gap-3 px-4 pt-4">
423
- <p className="text-xs text-base-contrast">{conflict.message}</p>
424
- <Button variant="secondary" onClick={reloadPublished} disabled={busy}>
425
- Reload the published deck
426
- </Button>
427
- <Button variant="ghost" onClick={() => setConflict(null)}>
428
- Keep my version
429
- </Button>
430
- </div>
431
- )}
432
- {notice && (
433
- <div
434
- role={notice.tone === "error" ? "alert" : "status"}
435
- className="mx-auto max-w-screen-xl px-4 pt-4 text-xs text-base-contrast"
436
- >
437
- <p>{notice.text}</p>
438
- {notice.details && (
439
- <ul className="mt-1 list-disc pl-4 text-base-contrast-light">
440
- {notice.details.map((detail) => (
441
- <li key={detail}>{detail}</li>
442
- ))}
443
- </ul>
444
- )}
445
- </div>
446
- )}
447
-
448
- {openRow ? (
449
- <DeckEditor
450
- deck={openRow.deck}
451
- mediaAssets={mediaAssets}
452
- currentSlide={slide}
453
- onSelectSlide={setSlide}
454
- onBack={() => setOpenDeckId(null)}
455
- onEditText={editText}
456
- onMoveSlide={moveSlide}
457
- onDuplicateSlide={duplicateSlide}
458
- onDeleteSlide={deleteSlide}
459
- // Only a streaming turn locks the stage. `busy` (publish, reopen)
460
- // deliberately does NOT: those are short, and the whole reason the
461
- // publish baseline is now re-read is so a mid-flight edit is safe.
462
- locked={chatBusy}
463
- statusLine={
464
- dirty
465
- ? openRow.baseVersion === null
466
- ? "Never published — staged on this device"
467
- : "Unpublished changes on this device"
468
- : "Published"
469
- }
470
- />
471
- ) : loading ? (
472
- <p className="mx-auto max-w-screen-xl px-4 pt-8 text-sm text-base-contrast-light">Loading your decks…</p>
473
- ) : (
474
- <DeckLibrary
475
- entries={entries}
476
- mediaAssets={mediaAssets}
477
- canCreate={canCreate}
478
- onOpen={(id) => void openDeck(id)}
479
- onCreate={() => void createDeck()}
480
- onDelete={(id) => void removeDeck(id)}
481
- />
482
- )}
483
- </div>
484
-
485
- {/* Docked only while a deck is open. A turn rewrites the WHOLE deck
486
- (ADR 0001), so there has to be one to rewrite — creating is the
487
- "New deck" button's job, and the starter deck is what the first
488
- turn edits. */}
489
- {openRow &&
490
- (chatOpen ? (
491
- <DeckChatPanel
492
- deck={openRow.deck}
493
- // Same staleness as publish, and the acceptance criterion "a manual
494
- // edit is visible to the model on the next turn" is exactly this:
495
- // Send blurs the open field, so the row has not caught up yet.
496
- getDeck={currentDeck}
497
- scope={draftScope}
498
- onDeck={(next) => void applyDeck(next)}
499
- onBusyChange={setChatBusy}
500
- // The other half of the publish lock. Publish is disabled while a
501
- // turn streams; this is what stops a turn STARTING inside a
502
- // publish, whose tail rewrites the draft row from a pre-request
503
- // snapshot (see the ⚠️ in `publish`).
504
- disabled={busy}
505
- onClose={() => setChatOpen(false)}
506
- />
507
- ) : (
508
- <button
509
- type="button"
510
- onClick={() => setChatOpen(true)}
511
- aria-label="Show deck assistant"
512
- className="fixed right-4 bottom-4 z-50 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90"
513
- >
514
- <MessageSquareIcon size={18} aria-hidden="true" />
515
- </button>
516
- ))}
517
- </div>
518
- </div>
519
- );
520
- }
521
-
522
- /** The grid's rows: every draft on this device, joined to the branch listing. */
523
- function buildEntries(drafts: DeckDraftRow[], published: PublishedDeckSummary[]): DeckGridEntry[] {
524
- const byId = new Map<string, DeckGridEntry>();
525
- for (const row of drafts) {
526
- byId.set(row.deckId, {
527
- id: row.deckId,
528
- title: row.deck.title,
529
- slideCount: row.deck.slides.length,
530
- updatedAt: row.updatedAt,
531
- draft: { deck: row.deck, baseVersion: row.baseVersion },
532
- published: null,
533
- unpublishedChanges: hasUnpublishedChanges(row),
534
- });
535
- }
536
- for (const summary of published) {
537
- const existing = byId.get(summary.id);
538
- if (existing) {
539
- existing.published = { version: summary.version };
540
- continue;
541
- }
542
- // Published from another browser (or after this one's draft was cleared).
543
- // Listed from the meta sidecar alone — opening it is what fetches the body.
544
- byId.set(summary.id, {
545
- id: summary.id,
546
- title: summary.meta?.title ?? summary.id,
547
- slideCount: summary.meta?.slideCount ?? 0,
548
- updatedAt: summary.meta?.updatedAt ?? new Date(0).toISOString(),
549
- draft: null,
550
- published: { version: summary.version },
551
- unpublishedChanges: false,
552
- });
553
- }
554
- return [...byId.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
555
- }
556
-
557
- function noticeFor(failure: { ok: false } & DeckApiFailure, fallback: string): Notice {
558
- switch (failure.kind) {
559
- case "invalid":
560
- return { tone: "error", text: "The deck did not pass validation.", details: failure.details };
561
- case "forbidden":
562
- return { tone: "error", text: failure.message };
563
- case "notFound":
564
- return { tone: "error", text: "That deck is no longer there." };
565
- case "conflict":
566
- case "unavailable":
567
- case "failed":
568
- return { tone: "error", text: `${fallback} ${failure.message}` };
569
- }
570
- }
571
-
572
- export default DeckBuilderApp;
@@ -1,62 +0,0 @@
1
- import { ArrowLeftIcon, LayoutGridIcon } from "lucide-react";
2
- import type { ReactNode } from "react";
3
- import { IconButton } from "../components/shared/IconButton";
4
- import { Tooltip } from "../components/shared/Tooltip";
5
-
6
- /**
7
- * Builder mode's chrome (the 2026-08-05 prototype verdict: variant B's bar).
8
- *
9
- * A hand-for-hand match of EditorShell's toolbar — full-bleed bordered band,
10
- * `max-w-screen-xl` inner grid of three columns at `h-16`, framed icon buttons
11
- * on the right — because this bar REPLACES the portal sidebar while builder
12
- * mode is open, and the two surfaces should not read as different products.
13
- * Not `fixed`: the builder owns the whole viewport and lays out as a column, so
14
- * the bar is a flex child that nothing can scroll under.
15
- */
16
- interface Props {
17
- /** Left cell: the primary action for whichever view is showing. */
18
- primaryAction: ReactNode;
19
- /** Center cell: one line of draft/deck status. */
20
- status: ReactNode;
21
- /** Where leaving builder mode goes — the page the FAB was pressed on. */
22
- exitHref: string;
23
- /** Shown while a deck is open; returns to the grid. */
24
- onBackToLibrary?: () => void;
25
- }
26
-
27
- export function DeckBuilderTopBar({ primaryAction, status, exitHref, onBackToLibrary }: Props) {
28
- return (
29
- <div className="shrink-0 border-b border-base-200 bg-base">
30
- <div className="mx-auto grid h-16 max-w-screen-xl grid-cols-3 items-center gap-4 px-4">
31
- <div className="flex min-w-0 items-center gap-2">{primaryAction}</div>
32
- <div className="flex items-center justify-center">
33
- <p className="truncate text-xs text-base-contrast-light">{status}</p>
34
- </div>
35
- <div className="flex items-center justify-end gap-2">
36
- {onBackToLibrary && (
37
- <Tooltip side="bottom" content="Deck library">
38
- <IconButton
39
- icon={<LayoutGridIcon size={16} />}
40
- label="Deck library"
41
- size="md"
42
- onClick={onBackToLibrary}
43
- className="border border-base-200 bg-base-accent"
44
- />
45
- </Tooltip>
46
- )}
47
- <div aria-hidden="true" className="mx-1 h-6 w-px self-center bg-base-200" />
48
- {/* An anchor, not a Button with a click handler: leaving builder mode
49
- is a navigation, and it should survive middle-click and a failed
50
- hydration alike. Classes mirror Button variant="secondary". */}
51
- <a
52
- href={exitHref}
53
- className="inline-flex cursor-pointer items-center justify-center rounded border border-base-200 px-3 py-1.5 text-xs font-medium text-base-contrast transition-colors hover:bg-base-accent"
54
- >
55
- <ArrowLeftIcon size={14} className="mr-1.5" aria-hidden="true" />
56
- Leave builder
57
- </a>
58
- </div>
59
- </div>
60
- </div>
61
- );
62
- }