@drawnagency/primitives 0.1.111 → 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 (185) 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.d.ts.map +1 -1
  125. package/dist/media/index.js +3 -3
  126. package/dist/schemas/deck-meta.d.ts +85 -0
  127. package/dist/schemas/deck-meta.d.ts.map +1 -0
  128. package/dist/schemas/deck-meta.js +64 -0
  129. package/dist/schemas/deck.d.ts +954 -0
  130. package/dist/schemas/deck.d.ts.map +1 -0
  131. package/dist/schemas/deck.js +65 -0
  132. package/dist/schemas/index.js +39 -37
  133. package/dist/schemas/site-config.d.ts +13 -1
  134. package/dist/schemas/site-config.d.ts.map +1 -1
  135. package/package.json +37 -1
  136. package/src/components/chat/ChatComposer.tsx +100 -0
  137. package/src/components/chat/ChatPanel.tsx +20 -69
  138. package/src/components/chat/markdown.ts +32 -0
  139. package/src/components/primitives/EditablePlainText.tsx +11 -1
  140. package/src/components/shell/AudienceChips.tsx +30 -0
  141. package/src/components/shell/EditorShell.tsx +12 -0
  142. package/src/components/shell/SiteSettingsChatbot.tsx +1 -18
  143. package/src/components/shell/SiteSettingsDecks.tsx +41 -0
  144. package/src/components/shell/SiteSettingsModal.tsx +11 -0
  145. package/src/deck/DeckComponentView.tsx +28 -0
  146. package/src/deck/DeckPlayer.tsx +166 -0
  147. package/src/deck/DeckSlideView.tsx +38 -0
  148. package/src/deck/DeckStage.tsx +39 -0
  149. package/src/deck/components/DeckImage.tsx +38 -0
  150. package/src/deck/components/DeckImageGrid.tsx +254 -0
  151. package/src/deck/components/DeckRichText.tsx +111 -0
  152. package/src/deck/components/DeckVideo.tsx +81 -0
  153. package/src/deck/editing.tsx +201 -0
  154. package/src/deck/flip.ts +70 -0
  155. package/src/deck/index.ts +30 -0
  156. package/src/deck/layouts/CenteredTextLayout.tsx +29 -0
  157. package/src/deck/layouts/ColumnSplitLayout.tsx +69 -0
  158. package/src/deck/layouts/FullBleedImageLayout.tsx +58 -0
  159. package/src/deck/media.tsx +46 -0
  160. package/src/deck/tokens.ts +57 -0
  161. package/src/deck/useStageScale.ts +44 -0
  162. package/src/deck-builder/DeckBuilderApp.tsx +572 -0
  163. package/src/deck-builder/DeckBuilderTopBar.tsx +62 -0
  164. package/src/deck-builder/DeckChatPanel.tsx +334 -0
  165. package/src/deck-builder/DeckEditor.tsx +258 -0
  166. package/src/deck-builder/DeckLibrary.tsx +195 -0
  167. package/src/deck-builder/DeckSlideSurface.tsx +55 -0
  168. package/src/deck-builder/deck-api.ts +177 -0
  169. package/src/deck-builder/deck-chat-client.ts +129 -0
  170. package/src/deck-builder/deck-chat-history.ts +62 -0
  171. package/src/deck-builder/index.ts +12 -0
  172. package/src/deck-builder/starter-deck.ts +86 -0
  173. package/src/deck-builder/types.ts +24 -0
  174. package/src/deck-builder/useSortableSlide.ts +102 -0
  175. package/src/lib/deck-access.ts +132 -0
  176. package/src/lib/deck-dexie.ts +190 -0
  177. package/src/lib/deck-edit.ts +196 -0
  178. package/src/lib/deck-media-assets.ts +57 -0
  179. package/src/lib/deck-media-refs.ts +67 -0
  180. package/src/lib/deck-tool.ts +95 -0
  181. package/src/lib/nav.ts +21 -1
  182. package/src/schemas/deck-meta.ts +88 -0
  183. package/src/schemas/deck.ts +478 -0
  184. package/src/schemas/index.ts +4 -0
  185. package/src/schemas/site-config.ts +24 -1
@@ -0,0 +1,86 @@
1
+ import { DECK_SCHEMA_VERSION, DeckSchema, type Deck } from "../schemas/deck";
2
+
3
+ /**
4
+ * A new deck's seed. Three slides using the two text-only Layouts, so a New
5
+ * Deck is publishable and presentable before the author has said anything —
6
+ * and so the starter never cites media that a given site may not have (a
7
+ * reference the manifest cannot resolve renders as a gap, which is a poor
8
+ * first impression of the runtime).
9
+ *
10
+ * Parsed rather than cast: this is the one deck the product itself authors, so
11
+ * a schema change that breaks it should break a test, not a client's first
12
+ * deck.
13
+ */
14
+ export function starterDeck(id: string, title: string): Deck {
15
+ return DeckSchema.parse({
16
+ schemaVersion: DECK_SCHEMA_VERSION,
17
+ id,
18
+ title,
19
+ slides: [
20
+ {
21
+ id: "cover",
22
+ title: "Cover",
23
+ layout: {
24
+ type: "centeredText",
25
+ background: "brand",
26
+ content: {
27
+ type: "richText",
28
+ kicker: "New deck",
29
+ heading: title,
30
+ subheading: "Rename this slide's heading, or ask the assistant to rewrite the whole deck.",
31
+ },
32
+ },
33
+ },
34
+ {
35
+ id: "context",
36
+ title: "Context",
37
+ layout: {
38
+ type: "centeredText",
39
+ content: {
40
+ type: "richText",
41
+ kicker: "Context",
42
+ heading: "What this deck is for",
43
+ paragraphs: [
44
+ "Every slide uses one of the portal's prebuilt Layouts, so it inherits the brand's type, color and spacing automatically.",
45
+ ],
46
+ },
47
+ },
48
+ },
49
+ {
50
+ id: "next",
51
+ title: "Next",
52
+ layout: {
53
+ type: "centeredText",
54
+ content: {
55
+ type: "richText",
56
+ kicker: "Next",
57
+ heading: "Three things to try",
58
+ bullets: [
59
+ "Click a thumbnail below to move through the deck",
60
+ "Publish when you want the deck kept for you",
61
+ "Everything you change stays on this device until you do",
62
+ ],
63
+ },
64
+ },
65
+ },
66
+ ],
67
+ });
68
+ }
69
+
70
+ /**
71
+ * A deck id for a new deck: a slug of the title plus a short random suffix.
72
+ *
73
+ * Random rather than sequential because the id is a directory name on a shared
74
+ * branch that this browser cannot enumerate (a viewer only ever sees their own
75
+ * decks), so "next free number" is not something the builder can know. The
76
+ * charset is `DeckIdSchema`'s.
77
+ */
78
+ export function newDeckId(title: string): string {
79
+ const slug = title
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9]+/g, "-")
82
+ .replace(/^-+|-+$/g, "")
83
+ .slice(0, 40);
84
+ const suffix = Math.random().toString(36).slice(2, 8).replace(/[^a-z0-9]/g, "0").padEnd(6, "0");
85
+ return slug ? `${slug}-${suffix}` : `deck-${suffix}`;
86
+ }
@@ -0,0 +1,24 @@
1
+ import type { Deck } from "../schemas/deck";
2
+
3
+ /**
4
+ * One row of the builder's deck grid: whatever this browser has staged, joined
5
+ * to whatever the decks branch holds.
6
+ *
7
+ * The join is by deck id and both sides are optional, because the three states
8
+ * are all real: a draft never published (published side absent), a deck
9
+ * published from another device (draft side absent), and the normal case where
10
+ * both exist and may differ.
11
+ */
12
+ export interface DeckGridEntry {
13
+ id: string;
14
+ title: string;
15
+ slideCount: number;
16
+ /** ISO timestamp behind the card's "… ago" line. */
17
+ updatedAt: string;
18
+ /** Present when this browser has the deck staged. */
19
+ draft: { deck: Deck; baseVersion: string | null } | null;
20
+ /** Present when the decks branch holds it. */
21
+ published: { version: string } | null;
22
+ /** The draft differs from what was published (or was never published). */
23
+ unpublishedChanges: boolean;
24
+ }
@@ -0,0 +1,102 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { computeReorderTarget } from "../lib/reorder";
3
+
4
+ interface Options {
5
+ index: number;
6
+ onReorder: (fromIndex: number, toIndex: number) => void;
7
+ /** False while a chat turn streams — the strip goes inert, like the stage. */
8
+ enabled: boolean;
9
+ }
10
+
11
+ const DRAG_TYPE = "deck-filmstrip-slide";
12
+
13
+ /**
14
+ * Drag-to-reorder for one filmstrip thumbnail.
15
+ *
16
+ * A sibling of `useSortableRow` rather than an option on it: the filmstrip is
17
+ * HORIZONTAL, so it reads left/right edges where every existing consumer reads
18
+ * top/bottom, and widening that hook's `closestEdge` type would change the two
19
+ * shipped editor modals that render `DropEdgeIndicator` from it. The Deck
20
+ * Builder is a sealed subtree; a second small hook here costs less than a type
21
+ * change under two surfaces this ticket is not touching.
22
+ *
23
+ * The pointer-math is NOT duplicated: `computeReorderTarget` is written in
24
+ * before/after terms, so left maps to "top" and right to "bottom" and the
25
+ * unit-tested decrement stays the one implementation. The whole atlaskit
26
+ * adapter is imported lazily, as it is everywhere else — jsdom cannot run it,
27
+ * which is why `moveDeckSlide` and the move affordances carry the coverage.
28
+ */
29
+ export function useSortableSlide<E extends HTMLElement>({ index, onReorder, enabled }: Options) {
30
+ const ref = useRef<E>(null);
31
+ // Held in a ref so the effect does not depend on it. `onReorder` is rebuilt
32
+ // whenever the draft refreshes — i.e. after every single edit — and depending
33
+ // on it tore down and re-registered the drag adapter on every thumbnail in
34
+ // the strip each time.
35
+ const onReorderRef = useRef(onReorder);
36
+ onReorderRef.current = onReorder;
37
+ const [isDragging, setIsDragging] = useState(false);
38
+ const [closestEdge, setClosestEdge] = useState<"left" | "right" | null>(null);
39
+
40
+ useEffect(() => {
41
+ if (!enabled) return;
42
+ const element = ref.current;
43
+ if (!element) return;
44
+
45
+ let cleanup: (() => void) | undefined;
46
+ let cancelled = false;
47
+
48
+ void Promise.all([
49
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
50
+ import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
51
+ ]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
52
+ if (cancelled) return;
53
+
54
+ const edgeOf = (data: Record<string | symbol, unknown>): "left" | "right" | null => {
55
+ const edge = extractClosestEdge(data);
56
+ return edge === "left" || edge === "right" ? edge : null;
57
+ };
58
+
59
+ const cleanupDraggable = draggable({
60
+ element,
61
+ getInitialData: () => ({ dragType: DRAG_TYPE, index }),
62
+ onGenerateDragPreview: () => {
63
+ element.style.opacity = "0.4";
64
+ requestAnimationFrame(() => {
65
+ element.style.opacity = "";
66
+ });
67
+ },
68
+ onDragStart: () => setIsDragging(true),
69
+ onDrop: () => setIsDragging(false),
70
+ });
71
+
72
+ const cleanupDropTarget = dropTargetForElements({
73
+ element,
74
+ canDrop: ({ source }) => source.data.dragType === DRAG_TYPE,
75
+ getData: ({ input, element: target }) =>
76
+ attachClosestEdge({ index }, { input, element: target, allowedEdges: ["left", "right"] }),
77
+ onDragEnter: ({ self }) => setClosestEdge(edgeOf(self.data)),
78
+ onDrag: ({ self }) => setClosestEdge(edgeOf(self.data)),
79
+ onDragLeave: () => setClosestEdge(null),
80
+ onDrop: ({ source, self }) => {
81
+ setClosestEdge(null);
82
+ const fromIndex = source.data.index as number;
83
+ const edge = edgeOf(self.data);
84
+ const toIndex = computeReorderTarget(fromIndex, index, edge === "right" ? "bottom" : edge === "left" ? "top" : null);
85
+ if (toIndex !== null) onReorderRef.current(fromIndex, toIndex);
86
+ },
87
+ });
88
+
89
+ cleanup = () => {
90
+ cleanupDraggable();
91
+ cleanupDropTarget();
92
+ };
93
+ });
94
+
95
+ return () => {
96
+ cancelled = true;
97
+ cleanup?.();
98
+ };
99
+ }, [index, enabled]);
100
+
101
+ return { ref, isDragging, closestEdge };
102
+ }
@@ -0,0 +1,132 @@
1
+ import type { DeckOwner } from "../schemas/deck-meta";
2
+
3
+ /**
4
+ * Who is asking. Built from the request's identity — a signed audience cookie
5
+ * (with the credential username when the site has named credentials) or an
6
+ * editor session — and deliberately NOT an Astro `locals`: the decision below
7
+ * is pure, so it is testable the way `audiencePasses` is.
8
+ *
9
+ * `audience` (no username) is the env-mode standalone case: a shared password
10
+ * per audience means there is no person to attribute to (ADR 0002).
11
+ */
12
+ export type DeckIdentity =
13
+ | { kind: "viewer"; username: string; audience: string }
14
+ | { kind: "editor"; userId: string }
15
+ | { kind: "audience"; audience: string };
16
+
17
+ /** The owner to record on a deck this identity is creating. */
18
+ export function deckOwnerFor(identity: DeckIdentity): DeckOwner {
19
+ switch (identity.kind) {
20
+ case "viewer":
21
+ return { kind: "viewer", username: identity.username, audience: identity.audience };
22
+ case "editor":
23
+ return { kind: "editor", userId: identity.userId };
24
+ case "audience":
25
+ return { kind: "audience", audience: identity.audience };
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Ownership. Credential-keyed for viewers (the username is the person-level
31
+ * key — an audience change must not hand a deck to someone else, and must not
32
+ * take it away from its author), user-id-keyed for editors, and audience-keyed
33
+ * for the env-mode fallback.
34
+ *
35
+ * An audience-owned deck is owned by everyone in that audience, which is
36
+ * exactly the attribution loss ADR 0002 accepts for env-mode portals. The
37
+ * reverse does not hold: an identity with no username cannot claim a
38
+ * credential-owned deck.
39
+ */
40
+ export function isDeckOwner(owner: DeckOwner, identity: DeckIdentity): boolean {
41
+ switch (owner.kind) {
42
+ case "viewer":
43
+ return identity.kind === "viewer" && identity.username === owner.username;
44
+ case "editor":
45
+ return identity.kind === "editor" && identity.userId === owner.userId;
46
+ case "audience":
47
+ return (identity.kind === "viewer" || identity.kind === "audience") && identity.audience === owner.audience;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * May this identity see and change this deck? Owner, or any editor — editors
53
+ * are super-admins over every deck on the site regardless of audience config
54
+ * (feasibility decision 9).
55
+ *
56
+ * One function for read and for write because today they are the same rule:
57
+ * a deck is private to its owner (Deck Visibility). Audience grants and Public
58
+ * Links (#36/#37) widen READ only, and that is when the two split.
59
+ */
60
+ export function canAccessDeck(owner: DeckOwner, identity: DeckIdentity): boolean {
61
+ return identity.kind === "editor" || isDeckOwner(owner, identity);
62
+ }
63
+
64
+ /**
65
+ * May this identity CREATE a deck? Editors always (super-admins); everyone else
66
+ * only from a deck-enabled audience — `deckBuilder.audiences`, strict opt-in
67
+ * exactly like `chatbot.audiences`, so an empty list means no viewer may.
68
+ *
69
+ * Creation rights are AUDIENCE-keyed while ownership of the result is
70
+ * CREDENTIAL-keyed (`deckOwnerFor`); the two deliberately do not share a key.
71
+ * An `audience` identity (env-mode standalone, shared password, no named
72
+ * credential) passes when its audience is listed: that is a deliberate opt-in
73
+ * by the editor, and the resulting deck is audience-owned — the attribution
74
+ * loss ADR 0002 accepts, not an accident. What the list prevents is the DEFAULT
75
+ * audience being enabled implicitly, i.e. anyone holding the site password
76
+ * writing to the client's repo.
77
+ */
78
+ export function canCreateDeck(identity: DeckIdentity, deckAudiences: readonly string[]): boolean {
79
+ if (identity.kind === "editor") return true;
80
+ return deckAudiences.includes(identity.audience);
81
+ }
82
+
83
+ /** FNV-1a, twice, at different offsets — 16 hex chars with no crypto import and
84
+ * no async. Partitioning only; see `deckDraftScope` for why that is enough. */
85
+ function digest(input: string): string {
86
+ const half = (offset: number): string => {
87
+ let hash = offset;
88
+ for (let i = 0; i < input.length; i++) {
89
+ hash ^= input.charCodeAt(i);
90
+ hash = Math.imul(hash, 0x01000193) >>> 0;
91
+ }
92
+ return hash.toString(16).padStart(8, "0");
93
+ };
94
+ return half(0x811c9dc5) + half(0x7fffffff);
95
+ }
96
+
97
+ /**
98
+ * The Deck Draft partition key for an identity.
99
+ *
100
+ * A Deck Draft lives in one browser until it is published (ADR 0002), and a
101
+ * browser PROFILE is shared between people far more often than an account is.
102
+ * Keyed by site alone, two viewers signing into the same portal on one machine
103
+ * saw each other's unpublished drafts in the grid, could open and read them,
104
+ * and could publish another person's never-published draft under their own
105
+ * ownership; a signed-out user's drafts simply waited for whoever came next.
106
+ *
107
+ * The partition mirrors `deckOwnerFor` exactly, so a draft can only ever be
108
+ * published under the identity that staged it.
109
+ *
110
+ * DIGESTED, not spelled out, because the value becomes an IndexedDB database
111
+ * NAME and any script on the origin can enumerate those — a raw username there
112
+ * would disclose who last used the browser. This is NOT a security boundary:
113
+ * IndexedDB is per-origin and isolates nothing between people sharing a
114
+ * profile. It bounds accidents, not somebody with the keyboard.
115
+ *
116
+ * Switching identity leaves the previous identity's database in place,
117
+ * untouched and unread. That is deliberate — the alternative is destroying a
118
+ * colleague's unpublished work on sign-in — so orphaned drafts persist until
119
+ * the browser's own storage eviction takes them.
120
+ */
121
+ export function deckDraftScope(identity: DeckIdentity): string {
122
+ switch (identity.kind) {
123
+ case "viewer":
124
+ return digest(`viewer:${identity.username}@${identity.audience}`);
125
+ case "editor":
126
+ return digest(`editor:${identity.userId}`);
127
+ case "audience":
128
+ // Env-mode standalone: a shared password means there is no person to
129
+ // separate, and ownership degrades the same way (ADR 0002).
130
+ return digest(`audience:${identity.audience}`);
131
+ }
132
+ }
@@ -0,0 +1,190 @@
1
+ import Dexie from "dexie";
2
+ import { serializeDeck, type Deck } from "../schemas/deck";
3
+
4
+ /**
5
+ * Deck Draft staging — the deck builder's half of the editor's unsaved-state
6
+ * model (`./dexie.ts`), and the reason the authoring loop performs no server
7
+ * writes at all (ADR 0002). A draft lives in this browser only until an
8
+ * explicit publish; nothing here ever talks to the network.
9
+ *
10
+ * A SEPARATE database from the editor's `brand-portal-{siteId}`, not a table on
11
+ * it. The builder is a VIEWER surface: opening the editor's connection from it
12
+ * would run the editor's upgrade chain (11 versions, several of which clear
13
+ * caches) in a viewer's browser, and would hold a connection open that blocks
14
+ * the editor's next schema bump. Nothing here needs the editor's rows.
15
+ *
16
+ * Keyed by site AND by identity scope (`deckDraftScope`). One browser profile
17
+ * routinely serves several people, and drafts are unpublished work — see that
18
+ * function for what the scope is, why it is a digest, and what happens to the
19
+ * databases an identity leaves behind.
20
+ */
21
+
22
+ export interface DeckDraftRow {
23
+ /** Deck id — also the directory name on the decks branch. */
24
+ deckId: string;
25
+ deck: Deck;
26
+ /**
27
+ * The published version this draft was based on, and the `baseVersion` the
28
+ * next publish sends. `null` means the deck has never been published, which
29
+ * is exactly what POST /api/decks wants to be told to create it.
30
+ */
31
+ baseVersion: string | null;
32
+ /**
33
+ * The published deck's serialized bytes AT `baseVersion`, or null when it has
34
+ * never been published. Stored rather than a dirty flag because the flag has
35
+ * to survive a reload, and re-deriving it would mean a server read — which is
36
+ * the one thing a draft must not need. Comparing bytes also makes an
37
+ * edit-then-undo correctly report "no unpublished changes".
38
+ */
39
+ publishedText: string | null;
40
+ updatedAt: string;
41
+ }
42
+
43
+ class DeckDraftDatabase extends Dexie {
44
+ drafts!: Dexie.Table<DeckDraftRow, string>;
45
+
46
+ constructor(name: string) {
47
+ super(name);
48
+ this.version(1).stores({ drafts: "deckId" });
49
+ }
50
+ }
51
+
52
+ /** `scope` is a `deckDraftScope` digest — never a raw username or user id. */
53
+ function databaseName(siteId: string, scope: string): string {
54
+ return `brand-portal-decks-${siteId}-${scope}`;
55
+ }
56
+
57
+ let db: DeckDraftDatabase | null = null;
58
+
59
+ export function initDeckDraftStore(siteId: string, scope: string): void {
60
+ const name = databaseName(siteId, scope);
61
+ // Re-initialising for the database we already hold is a no-op — replacing the
62
+ // instance would leave the old connection open, and an open connection on the
63
+ // previous version is what makes the next schema bump hang on `blocked`.
64
+ // (Same reasoning as initEditorStore.) Switching identity DOES replace it,
65
+ // which is the point: the previous identity's rows become unreachable here.
66
+ if (db?.name === name) return;
67
+ db?.close();
68
+ db = new DeckDraftDatabase(name);
69
+ }
70
+
71
+ function getDb(): DeckDraftDatabase {
72
+ if (!db) throw new Error("Deck draft store not initialized. Call initDeckDraftStore first.");
73
+ return db;
74
+ }
75
+
76
+ /** Every staged draft, newest first. Drives the builder's deck grid. */
77
+ export async function listDeckDrafts(): Promise<DeckDraftRow[]> {
78
+ const rows = await getDb().drafts.toArray();
79
+ return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
80
+ }
81
+
82
+ export async function getDeckDraft(deckId: string): Promise<DeckDraftRow | null> {
83
+ return (await getDb().drafts.get(deckId)) ?? null;
84
+ }
85
+
86
+ /**
87
+ * Stage a deck edit. `baseVersion`/`publishedText` are carried forward from the
88
+ * existing row rather than taken as arguments: an edit never changes what the
89
+ * server holds, and letting a caller pass them is how a draft would come to
90
+ * claim it was published when it was not.
91
+ */
92
+ export async function putDeckDraft(deck: Deck): Promise<DeckDraftRow> {
93
+ const db = getDb();
94
+ // Transactional, not get-then-put: this races `recordDeckPublish` on exactly
95
+ // the fields it carries forward. Two overlapping read-modify-writes both
96
+ // reading the pre-publish row would revert `baseVersion` to null, and the next
97
+ // publish would then send `baseVersion: null` for a deck that already exists —
98
+ // a 409 the author cannot explain. IDB serializes readwrite transactions on
99
+ // the store, so the two can only ever run one after the other.
100
+ return db.transaction("rw", db.drafts, async () => {
101
+ const existing = await db.drafts.get(deck.id);
102
+ const row: DeckDraftRow = {
103
+ deckId: deck.id,
104
+ deck,
105
+ baseVersion: existing?.baseVersion ?? null,
106
+ publishedText: existing?.publishedText ?? null,
107
+ updatedAt: new Date().toISOString(),
108
+ };
109
+ await db.drafts.put(row);
110
+ return row;
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Record what the server now holds — after a publish, or after loading a
116
+ * published deck into the draft. The deck body is restated from `deck` so the
117
+ * row and its published baseline can never disagree.
118
+ *
119
+ * `updatedAt` is an argument because the two callers mean different things by
120
+ * it. A publish genuinely changed the deck, so "now" is right. Merely OPENING a
121
+ * published deck changes nothing, and stamping "now" there sorted it to the top
122
+ * of the grid — reading a deck must not reorder the library — so that caller
123
+ * passes the sidecar's own timestamp.
124
+ */
125
+ export async function markDeckDraftPublished(deck: Deck, version: string, updatedAt?: string): Promise<DeckDraftRow> {
126
+ const row: DeckDraftRow = {
127
+ deckId: deck.id,
128
+ deck,
129
+ baseVersion: version,
130
+ publishedText: serializeDeck(deck),
131
+ updatedAt: updatedAt ?? new Date().toISOString(),
132
+ };
133
+ await getDb().drafts.put(row);
134
+ return row;
135
+ }
136
+
137
+ /**
138
+ * Record a successful publish WITHOUT touching the working deck (#39).
139
+ *
140
+ * The difference from `markDeckDraftPublished` is the whole point: that
141
+ * function restates the body from its argument, which is a pre-request snapshot
142
+ * at the publish call site, so an edit landing during the round-trip was
143
+ * silently discarded. #35 could answer that with a lock because its only other
144
+ * writer was a chat turn; inline editing cannot — locking a text field the
145
+ * author is mid-word in is worse than the race it prevents.
146
+ *
147
+ * So this re-reads the row and writes back only the publish BASELINE: what the
148
+ * server now holds (`baseVersion`) and the bytes it holds (`publishedText`,
149
+ * serialized from the deck that was actually sent). `deck` stays whatever is
150
+ * current by now, which makes `hasUnpublishedChanges` correctly report the
151
+ * mid-flight edit as unpublished rather than losing it.
152
+ *
153
+ * Returns null when the row is gone (deleted during the round-trip) — recreating
154
+ * it would resurrect a deck the author just removed.
155
+ */
156
+ export async function recordDeckPublish(deckId: string, publishedDeck: Deck, version: string): Promise<DeckDraftRow | null> {
157
+ const db = getDb();
158
+ // Transactional for the same reason `putDeckDraft` is, and it is the half
159
+ // that bites hardest: an inline commit whose put lands between this read and
160
+ // this write is REVERTED here — the row goes back to the pre-edit deck with a
161
+ // matching `publishedText`, so the edit is gone AND the deck reports itself
162
+ // clean. Re-reading inside the transaction is what makes "leave `deck` alone"
163
+ // mean the deck as of the write, not as of the request.
164
+ return db.transaction("rw", db.drafts, async () => {
165
+ const existing = await db.drafts.get(deckId);
166
+ if (!existing) return null;
167
+ const row: DeckDraftRow = {
168
+ ...existing,
169
+ baseVersion: version,
170
+ publishedText: serializeDeck(publishedDeck),
171
+ // "Now", as the previous implementation stamped: a publish is activity the
172
+ // deck grid should sort on, even though it did not change the body.
173
+ updatedAt: new Date().toISOString(),
174
+ };
175
+ await db.drafts.put(row);
176
+ return row;
177
+ });
178
+ }
179
+
180
+ export async function deleteDeckDraft(deckId: string): Promise<void> {
181
+ await getDb().drafts.delete(deckId);
182
+ }
183
+
184
+ /**
185
+ * Does this row differ from what was published? A never-published draft always
186
+ * has changes — there is nothing on the server to match.
187
+ */
188
+ export function hasUnpublishedChanges(row: DeckDraftRow): boolean {
189
+ return row.publishedText === null || serializeDeck(row.deck) !== row.publishedText;
190
+ }