@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,196 @@
1
+ import {
2
+ parseDeck,
3
+ type Deck,
4
+ type DeckComponent,
5
+ type DeckRichText,
6
+ type DeckSlide,
7
+ } from "../schemas/deck";
8
+
9
+ /**
10
+ * Direct-manipulation edits to a Deck Draft (#39).
11
+ *
12
+ * Every operation here is PURE and ends in `parseDeck`, which is what makes
13
+ * "a manual edit is always schema-valid" a property of this module rather than
14
+ * of its callers. It also means the schema owns the refusals: duplicating past
15
+ * `DECK_MAX_SLIDES` and deleting the last slide are reported with the same
16
+ * messages the authoring loop's validation retry reads, not with a second copy
17
+ * of those rules written here.
18
+ *
19
+ * The scope line is the product decision recorded in CONTEXT.md — "chat
20
+ * authors, direct manipulation adjusts". There is deliberately no operation
21
+ * here that changes a Layout, adds or removes a Deck Component, or composes a
22
+ * grid. Text leaves and slide order are the whole vocabulary.
23
+ */
24
+
25
+ /**
26
+ * Which Deck Component within a slide's Layout. A slide-local coordinate: the
27
+ * runtime renders one slide and has no idea which one, so the slide index stays
28
+ * an argument at the call site.
29
+ */
30
+ // Module-private VALUES, exported types only. The deck runtime imports
31
+ // `DeckTextPath` from here, and `import type` is erased — exporting the arrays
32
+ // would offer a value import that pulls this module, and the deck schema and
33
+ // zod behind it, into the runtime chunk served to Public Link readers. Neither
34
+ // fence would see it: the unit test cannot tell a type import from a value one
35
+ // and excludes the runtime subtree anyway.
36
+ const DECK_TEXT_SLOTS = ["content", "overlay", "left", "right"] as const;
37
+ export type DeckTextSlot = (typeof DECK_TEXT_SLOTS)[number];
38
+
39
+ const DECK_TEXT_FIELDS = ["kicker", "heading", "subheading", "paragraphs", "bullets"] as const;
40
+ export type DeckTextField = (typeof DECK_TEXT_FIELDS)[number];
41
+
42
+ /**
43
+ * A single editable text LEAF — always a plain string in the schema.
44
+ *
45
+ * That "always a leaf" framing is what keeps inline editing free of a
46
+ * formatting UI without ever destroying a mark: a paragraph carrying spans is
47
+ * edited one span at a time (`span`), so `bold`/`italic` are untouched by an
48
+ * edit that only ever writes `text`. A plain-string line has no `span`.
49
+ */
50
+ export interface DeckTextPath {
51
+ slot: DeckTextSlot;
52
+ field: DeckTextField;
53
+ /** Index into `paragraphs`/`bullets`. Absent for the three single-line fields. */
54
+ line?: number;
55
+ /** Index into that line's spans, when the line is a span run rather than a string. */
56
+ span?: number;
57
+ }
58
+
59
+ export type DeckEditResult = { ok: true; deck: Deck } | { ok: false; errors: string[] };
60
+
61
+ /** A path that points at nothing is a bug in the caller, not author input. */
62
+ function unresolved(path: DeckTextPath): DeckEditResult {
63
+ return {
64
+ ok: false,
65
+ errors: [`no editable text at ${path.slot}.${path.field}${path.line === undefined ? "" : `[${path.line}]`}`],
66
+ };
67
+ }
68
+
69
+ /** The rich text a slot holds, or null when that slot holds something else. */
70
+ export function richTextAt(slide: DeckSlide, slot: DeckTextSlot): DeckRichText | null {
71
+ const { layout } = slide;
72
+ if (layout.type === "centeredText") {
73
+ return slot === "content" && layout.content.type === "richText" ? layout.content : null;
74
+ }
75
+ if (layout.type === "fullBleedImage") {
76
+ return slot === "overlay" ? (layout.overlay?.content ?? null) : null;
77
+ }
78
+ if (slot !== "left" && slot !== "right") return null;
79
+ const component: DeckComponent = layout[slot];
80
+ return component.type === "richText" ? component : null;
81
+ }
82
+
83
+ /** The same addressing, as a setter. Returns null when the slot has no rich text. */
84
+ function withRichTextAt(slide: DeckSlide, slot: DeckTextSlot, next: DeckRichText): DeckSlide | null {
85
+ const { layout } = slide;
86
+ if (layout.type === "centeredText") {
87
+ if (slot !== "content" || layout.content.type !== "richText") return null;
88
+ return { ...slide, layout: { ...layout, content: next } };
89
+ }
90
+ if (layout.type === "fullBleedImage") {
91
+ if (slot !== "overlay" || layout.overlay === undefined) return null;
92
+ return { ...slide, layout: { ...layout, overlay: { ...layout.overlay, content: next } } };
93
+ }
94
+ if (slot !== "left" && slot !== "right") return null;
95
+ if (layout[slot].type !== "richText") return null;
96
+ return { ...slide, layout: { ...layout, [slot]: next } };
97
+ }
98
+
99
+ /** Writes `text` into the addressed leaf, or null when the path resolves to nothing. */
100
+ function withLeafText(content: DeckRichText, path: DeckTextPath, text: string): DeckRichText | null {
101
+ if (path.field === "kicker" || path.field === "heading" || path.field === "subheading") {
102
+ // Only a field that is already present is editable — a blank line the
103
+ // author never wrote is not a place to click, and inventing one here would
104
+ // be the structural editing this module does not do.
105
+ if (content[path.field] === undefined) return null;
106
+ return { ...content, [path.field]: text };
107
+ }
108
+
109
+ const lines = content[path.field];
110
+ if (lines === undefined || path.line === undefined || path.line < 0 || path.line >= lines.length) return null;
111
+
112
+ const nextLines = lines.slice();
113
+ const line = nextLines[path.line];
114
+
115
+ if (path.span === undefined) {
116
+ if (typeof line !== "string") return null;
117
+ nextLines[path.line] = text;
118
+ } else {
119
+ if (typeof line === "string" || path.span < 0 || path.span >= line.length) return null;
120
+ const spans = line.slice();
121
+ spans[path.span] = { ...spans[path.span], text };
122
+ nextLines[path.line] = spans;
123
+ }
124
+
125
+ return { ...content, [path.field]: nextLines };
126
+ }
127
+
128
+ /**
129
+ * Commit an inline text edit.
130
+ *
131
+ * Rejecting (rather than clamping) an over-long or emptied field is deliberate:
132
+ * the caps are the schema's, and a silent truncation is an edit the author did
133
+ * not make. The caller shows the schema's own message and leaves the draft
134
+ * untouched.
135
+ */
136
+ export function applyDeckTextEdit(deck: Deck, slideIndex: number, path: DeckTextPath, text: string): DeckEditResult {
137
+ const slide = deck.slides[slideIndex];
138
+ if (slide === undefined) return { ok: false, errors: [`no slide at index ${slideIndex}`] };
139
+
140
+ const content = richTextAt(slide, path.slot);
141
+ if (content === null) return unresolved(path);
142
+
143
+ const nextContent = withLeafText(content, path, text);
144
+ if (nextContent === null) return unresolved(path);
145
+
146
+ const nextSlide = withRichTextAt(slide, path.slot, nextContent);
147
+ if (nextSlide === null) return unresolved(path);
148
+
149
+ const slides = deck.slides.slice();
150
+ slides[slideIndex] = nextSlide;
151
+ return parseDeck({ ...deck, slides });
152
+ }
153
+
154
+ /** Move a slide to a new index. `to` is the destination in the POST-removal list. */
155
+ export function moveDeckSlide(deck: Deck, from: number, to: number): DeckEditResult {
156
+ if (from < 0 || from >= deck.slides.length) return { ok: false, errors: [`no slide at index ${from}`] };
157
+ if (to < 0 || to >= deck.slides.length) return { ok: false, errors: [`cannot move a slide to index ${to}`] };
158
+ const slides = deck.slides.slice();
159
+ const [moved] = slides.splice(from, 1);
160
+ slides.splice(to, 0, moved);
161
+ return parseDeck({ ...deck, slides });
162
+ }
163
+
164
+ /**
165
+ * A fresh slide id for a copy. Ids are unique within a deck (the schema refuses
166
+ * a duplicate by name) and double as diff anchors across revisions, so a copy
167
+ * has to be given its own rather than inheriting one.
168
+ */
169
+ function copySlideId(sourceId: string, taken: ReadonlySet<string>): string {
170
+ // Copying a copy chains "-copy-2", not "-copy-copy". The slice keeps room for
171
+ // the suffix inside DeckIdSchema's 64-character cap.
172
+ const stem = sourceId.replace(/-copy(-\d+)?$/, "").slice(0, 52).replace(/-+$/, "") || "slide";
173
+ let candidate = `${stem}-copy`;
174
+ for (let n = 2; taken.has(candidate); n++) candidate = `${stem}-copy-${n}`;
175
+ return candidate;
176
+ }
177
+
178
+ /** Duplicate a slide, inserting the copy directly after the original. */
179
+ export function duplicateDeckSlide(deck: Deck, index: number): DeckEditResult {
180
+ const source = deck.slides[index];
181
+ if (source === undefined) return { ok: false, errors: [`no slide at index ${index}`] };
182
+ const copy: DeckSlide = {
183
+ ...structuredClone(source),
184
+ id: copySlideId(source.id, new Set(deck.slides.map((s) => s.id))),
185
+ };
186
+ const slides = deck.slides.slice();
187
+ slides.splice(index + 1, 0, copy);
188
+ return parseDeck({ ...deck, slides });
189
+ }
190
+
191
+ /** Remove a slide. Removing the only one is refused by the schema, by design. */
192
+ export function removeDeckSlide(deck: Deck, index: number): DeckEditResult {
193
+ if (deck.slides[index] === undefined) return { ok: false, errors: [`no slide at index ${index}`] };
194
+ const slides = deck.slides.filter((_, i) => i !== index);
195
+ return parseDeck({ ...deck, slides });
196
+ }
@@ -0,0 +1,57 @@
1
+ import type { DeckMediaAsset } from "../schemas/deck";
2
+ import type { MediaManifest } from "../media/types";
3
+ import type { MediaProvider } from "../media/provider";
4
+
5
+ /**
6
+ * The production side of the deck runtime's media seam: the site's image
7
+ * manifest, resolved to the `mediaAssets` map `DeckPlayer` takes.
8
+ *
9
+ * A MAP, not a `DeckMediaResolver` function, because the builder mounts across
10
+ * an Astro island boundary and a function prop cannot survive that trip. It is
11
+ * built server-side, where the manifest and the media provider live.
12
+ *
13
+ * This replaces the dev preview route's generated plates
14
+ * (`core/src/lib/dev-deck-preview.ts`), which stay exactly where they are —
15
+ * they keep that route working offline and with no GitHub credentials.
16
+ */
17
+ export function deckMediaAssets(
18
+ manifest: MediaManifest,
19
+ sizes: number[],
20
+ provider: MediaProvider,
21
+ /**
22
+ * The audience boundary, when the caller has one. Only these ids resolve.
23
+ *
24
+ * The builder's map must match the catalog the model is offered (#35, decided
25
+ * 2026-08-07) or the two disagree about what exists — and because this map
26
+ * travels as ISLAND PROPS, an unfiltered one also puts every asset's filename
27
+ * and alt text in the page source of a creator who cannot see those pages.
28
+ * Omitted means no filtering, which is what the dev preview route wants.
29
+ */
30
+ allowIds?: ReadonlySet<string>,
31
+ ): Record<string, DeckMediaAsset> {
32
+ const assets: Record<string, DeckMediaAsset> = {};
33
+ for (const [id, item] of Object.entries(manifest.images)) {
34
+ // System-generated assets (the social-preview raster) are excluded from the
35
+ // media library UI, so they are not part of the catalog a deck may cite.
36
+ if (item.hidden) continue;
37
+ if (allowIds && !allowIds.has(id)) continue;
38
+ const resolved = provider.resolve(item, sizes);
39
+ // An unresolvable entry is simply absent, which the runtime already renders
40
+ // as a quiet gap — the same thing a deleted asset produces.
41
+ if (!resolved) continue;
42
+ assets[id] = {
43
+ src: resolved.src,
44
+ alt: item.alt,
45
+ // The manifest always knows the intrinsic size, and supplying it is what
46
+ // lets the image grid reserve aspect-correct space before a byte loads.
47
+ width: resolved.width,
48
+ height: resolved.height,
49
+ ...(resolved.tag === "video" && resolved.poster ? { poster: resolved.poster } : {}),
50
+ };
51
+ }
52
+ // A plain object, and that is safe because every reader guards its lookups
53
+ // with `Object.hasOwn` (DeckPlayer's map resolver, DeckSlideSurface) and the
54
+ // deck schema constrains a media id's charset. A null-prototype object would
55
+ // also have to be re-spread to survive Astro's prop serialization.
56
+ return assets;
57
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Every portal media reference a deck makes, extracted from RAW deck JSON.
3
+ *
4
+ * Publish-time GC builds its keep-set from the site's manifests and deletes
5
+ * every other bucket object (ADR 0002's "GC must learn the decks branch"), so a
6
+ * deck referencing an asset the editor later removed from portal content would
7
+ * otherwise have its media deleted underneath it.
8
+ *
9
+ * Deliberately untyped and structural rather than a walk over `Deck`: the media
10
+ * reference union lands with the image/grid components (#31), and this has to
11
+ * work the day it does without a second change here. It matches the union's
12
+ * discriminator shape — `{ kind: "portal", id: string }` — wherever it appears
13
+ * in the tree, at any depth, in any component. Staying schema-free is also what
14
+ * keeps this module off the deck-schema carrier list (tests/deck/viewer-isolation).
15
+ */
16
+
17
+ /** One reference, with the path an author (or a model) can act on. */
18
+ export interface DeckMediaRefSite {
19
+ /** `slides[2].layout.media` — the ref object's own path, not its `id` field. */
20
+ path: string;
21
+ id: string;
22
+ }
23
+
24
+ function step(path: string, key: string): string {
25
+ return path === "" ? key : `${path}.${key}`;
26
+ }
27
+
28
+ export function deckMediaRefSites(deck: unknown): DeckMediaRefSite[] {
29
+ const sites: DeckMediaRefSite[] = [];
30
+ const visit = (node: unknown, path: string): void => {
31
+ if (Array.isArray(node)) {
32
+ node.forEach((item, i) => visit(item, `${path}[${i}]`));
33
+ return;
34
+ }
35
+ if (typeof node !== "object" || node === null) return;
36
+ const record = node as Record<string, unknown>;
37
+ if (record.kind === "portal" && typeof record.id === "string" && record.id !== "") {
38
+ sites.push({ path, id: record.id });
39
+ }
40
+ for (const [key, value] of Object.entries(record)) visit(value, step(path, key));
41
+ };
42
+ visit(deck, "");
43
+ return sites;
44
+ }
45
+
46
+ export function deckMediaIds(deck: unknown): Set<string> {
47
+ return new Set(deckMediaRefSites(deck).map((site) => site.id));
48
+ }
49
+
50
+ /**
51
+ * Media references the catalog does not contain — the server-side half of media
52
+ * grounding (#35).
53
+ *
54
+ * The deck schema constrains a media id's CHARSET; only the catalog can say
55
+ * whether an id exists, so membership is checked here rather than in the schema
56
+ * (which is also read by the runtime, where no catalog is in hand). One line per
57
+ * offending reference, in the same `path: message` shape `formatDeckIssues`
58
+ * produces, so the authoring loop can hand both lists back in one tool_result.
59
+ */
60
+ export function deckUnknownMediaIssues(deck: unknown, catalogIds: ReadonlySet<string>): string[] {
61
+ return deckMediaRefSites(deck)
62
+ .filter((site) => !catalogIds.has(site.id))
63
+ .map(
64
+ (site) =>
65
+ `${step(site.path, "id")}: "${site.id}" is not in the portal media catalog — use only the ids listed under "Media catalog", copied exactly.`,
66
+ );
67
+ }
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import {
3
+ DECK_COMPONENT_TYPES,
4
+ DECK_LAYOUT_TYPES,
5
+ DECK_MAX_SLIDES,
6
+ DECK_SCHEMA_VERSION,
7
+ DeckSchema,
8
+ } from "../schemas/deck";
9
+
10
+ /**
11
+ * The `update_deck` tool the deck-chat loop hands the model (#35, ADR 0001).
12
+ *
13
+ * The input schema is DERIVED from `DeckSchema` rather than written out, so the
14
+ * contract cannot drift from what the server will actually accept: every
15
+ * `.describe()` in the deck schema IS the LLM's documentation, and a field
16
+ * added there reaches the model on the next request with no second edit here.
17
+ *
18
+ * Emitted INLINE (zod's default `reused: "inline"`), not with `$defs`/`$ref`.
19
+ * The ref form is less than half the bytes, but names every shared node
20
+ * `__schema7`, which puts the layout and component descriptions an indirection
21
+ * away from the slot that uses them. The tool block sits in the cached prefix
22
+ * (tools precede `system`, and the corpus block carries the cache breakpoint),
23
+ * so the size is paid once per conversation while the legibility is paid on
24
+ * every turn.
25
+ */
26
+
27
+ export const UPDATE_DECK_TOOL_NAME = "update_deck";
28
+
29
+ export interface DeckToolDefinition {
30
+ name: string;
31
+ description: string;
32
+ /** JSON Schema; the tool's input IS the whole deck. */
33
+ input_schema: Record<string, unknown>;
34
+ }
35
+
36
+ /**
37
+ * The vocabulary, spelled out in prose above the schema.
38
+ *
39
+ * The derived JSON Schema already carries it, but only as `const` values buried
40
+ * in nested `anyOf` branches — and live authoring showed the model guessing
41
+ * layout and component types from there, burning validation retries on
42
+ * "unknown layout type" and sometimes exhausting them. A flat list costs a few
43
+ * dozen tokens in the cached tool block and removes the guess.
44
+ *
45
+ * The type names come from the schema's own constants, so a new Layout or
46
+ * Component cannot be added without appearing here; the slot shapes are spelled
47
+ * out because they are structure, not a list — `tests/deck/deck-tool.test.ts`
48
+ * pins that every name in both constants is mentioned.
49
+ */
50
+ const VOCABULARY = [
51
+ `Layouts (a slide uses exactly one, and \`type\` must be one of these ${DECK_LAYOUT_TYPES.length}): ${DECK_LAYOUT_TYPES.map((t) => `\`${t}\``).join(", ")}.`,
52
+ "- `centeredText` — a title card. Slot: `content` (one Component). Optional `background`: `base` or `brand`.",
53
+ "- `fullBleedImage` — the image IS the slide. Takes `media` directly, plus an optional `overlay` panel whose `content` must be a `richText` Component. It has no other slots.",
54
+ "- `columnSplit` — two slots, `left` and `right`, each one Component, at a `ratio` of `1-2` or `2-1`.",
55
+ "",
56
+ `Components (what fills a Layout slot; \`type\` must be one of these ${DECK_COMPONENT_TYPES.length}): ${DECK_COMPONENT_TYPES.map((t) => `\`${t}\``).join(", ")}.`,
57
+ "- `richText` — kicker / heading / subheading / paragraphs / bullets. Text only; no media.",
58
+ "- `image` — one `media` reference and an optional caption.",
59
+ "- `imageGrid` — two to twelve `items`, each with its own `media`.",
60
+ "- `video` — one `media` reference.",
61
+ "",
62
+ "There is nothing else. A `type` outside those lists is rejected, and so is a slot a Layout does not have — do not invent `body`, `columns`, `slots` or `children`.",
63
+ ].join("\n");
64
+
65
+ const DESCRIPTION = [
66
+ "Replace the ENTIRE deck with a new version. This is how every change to the deck is made — adding a slide, rewriting a line, reordering, or building the deck from nothing.",
67
+ "",
68
+ VOCABULARY,
69
+ "",
70
+ "Rules:",
71
+ "- Emit the COMPLETE deck every time, including slides you are not changing. There is no patch or partial form: whatever you send becomes the deck, and anything you leave out is deleted.",
72
+ `- Keep \`id\` exactly as it is in the current deck, and keep \`schemaVersion\` at ${DECK_SCHEMA_VERSION}.`,
73
+ "- Reuse a slide's existing `id` when that slide survives the edit, so revisions stay diffable. Give a genuinely new slide a new lowercase-hyphen id that no other slide uses.",
74
+ "- Every `media` reference must be a portal media id copied exactly from the Media catalog in your instructions. Never invent one, and never use a filename, path or URL.",
75
+ `- At most ${DECK_MAX_SLIDES} slides.`,
76
+ "",
77
+ "Say what you changed in your reply text; do not restate the deck JSON there.",
78
+ ].join("\n");
79
+
80
+ export function updateDeckTool(): DeckToolDefinition {
81
+ const schema = z.toJSONSchema(DeckSchema, {
82
+ // `io: "input"` is what makes fields with defaults optional — under
83
+ // "output" the model would be told `background` is required. `unrepresentable
84
+ // : "any"` is for the superRefines (duplicate slide ids, the "a richText
85
+ // needs some content" rule); they are enforced server-side by parseDeck,
86
+ // and their messages are what a failed turn gets handed back.
87
+ target: "draft-2020-12",
88
+ io: "input",
89
+ unrepresentable: "any",
90
+ }) as Record<string, unknown>;
91
+ // The provider takes the schema body directly; `$schema` is inert there and
92
+ // is just tokens on every request.
93
+ delete schema.$schema;
94
+ return { name: UPDATE_DECK_TOOL_NAME, description: DESCRIPTION, input_schema: schema };
95
+ }
package/src/lib/nav.ts CHANGED
@@ -2,6 +2,7 @@ import type { LoadedSection } from "./loader";
2
2
  import { getSection, type SectionRegistry } from "./registry";
3
3
  import { audiencePasses, pageDisplayTitle } from "./pages";
4
4
  import { navEntries } from "./nav-tree";
5
+ import { getContainerChildren } from "./container-ops";
5
6
  import type { SiteIndex } from "../schemas/site-config";
6
7
  import type { SiteDocument } from "./documents";
7
8
 
@@ -28,6 +29,25 @@ export function toSectionId(text: string): string {
28
29
  return `section-${Math.abs(hash).toString(36)}`;
29
30
  }
30
31
 
32
+ /**
33
+ * Container children are real blocks on the page and their headings render with
34
+ * anchors like any other, so nav has to walk into them — a Sub Heading dropped
35
+ * into a container was silently missing from the sidebar. Children inherit the
36
+ * container's meta (its status governs theirs), and they take the container's
37
+ * slot in document order.
38
+ */
39
+ function flattenForNav(sections: LoadedSection[]): LoadedSection[] {
40
+ const flat: LoadedSection[] = [];
41
+ const walk = (loaded: LoadedSection) => {
42
+ flat.push(loaded);
43
+ for (const child of getContainerChildren(loaded.section)) {
44
+ walk({ section: child, meta: loaded.meta });
45
+ }
46
+ };
47
+ sections.forEach(walk);
48
+ return flat;
49
+ }
50
+
31
51
  export function generateNavLinks(
32
52
  sections: LoadedSection[],
33
53
  registry?: SectionRegistry,
@@ -41,7 +61,7 @@ export function generateNavLinks(
41
61
  return def?.navRole;
42
62
  };
43
63
 
44
- for (const { section, meta } of sections) {
64
+ for (const { section, meta } of flattenForNav(sections)) {
45
65
  const content = section.content as { heading?: string; excludeFromNav?: boolean };
46
66
  if (!content.heading) continue;
47
67
 
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+ import { DeckIdSchema, formatDeckIssues } from "./deck";
3
+
4
+ /**
5
+ * `decks/{deckId}/meta.json` — the sidecar beside a published deck on the
6
+ * Decks Branch (ADR 0002). It carries everything a listing needs, so the list
7
+ * route never reads a single `deck.json`.
8
+ *
9
+ * Ownership is dual-identity from day one (feasibility decision 9): a viewer
10
+ * credential, an editor session, or — on env-mode standalone portals, which
11
+ * have no named credentials — the audience alone. Retrofitting that later
12
+ * would mean rewriting every deck's meta.
13
+ */
14
+
15
+ /** Bumped when the sidecar's shape changes in a way old readers can't parse. */
16
+ export const DECK_META_SCHEMA_VERSION = 1;
17
+
18
+ /**
19
+ * Who owns a deck. `viewer` is the normal case (ownership is credential-keyed);
20
+ * `editor` covers a deck an agency editor built; `audience` is the env-mode
21
+ * standalone degradation, where a shared per-audience password means there is
22
+ * no person to attribute to.
23
+ */
24
+ export const DeckOwnerSchema = z.discriminatedUnion("kind", [
25
+ z.object({
26
+ kind: z.literal("viewer"),
27
+ /** The `viewer_credentials` username — the person-level key. */
28
+ username: z.string().min(1).max(200),
29
+ /** The audience that credential signed in as, for editor-side context. */
30
+ audience: z.string().min(1).max(200),
31
+ }),
32
+ z.object({
33
+ kind: z.literal("editor"),
34
+ userId: z.string().min(1).max(200),
35
+ }),
36
+ z.object({
37
+ kind: z.literal("audience"),
38
+ audience: z.string().min(1).max(200),
39
+ }),
40
+ ]);
41
+
42
+ /**
43
+ * Deliberately a LOOSE object: grants and the Public Link token land in later
44
+ * tickets (#36/#37), and a portal running an older package version must not
45
+ * silently strip fields it doesn't know when it rewrites this file. This is the
46
+ * same failure mode the nav sidecar exists to avoid — with unknown keys
47
+ * preserved, an old writer round-trips them instead of deleting them.
48
+ */
49
+ export const DeckMetaSchema = z.looseObject({
50
+ schemaVersion: z.literal(DECK_META_SCHEMA_VERSION),
51
+ id: DeckIdSchema,
52
+ /** Mirrors deck.title so a listing needs no deck body. */
53
+ title: z.string().min(1).max(120),
54
+ /** Mirrors deck.slides.length, for the same reason. */
55
+ slideCount: z.number().int().min(1),
56
+ owner: DeckOwnerSchema,
57
+ createdAt: z.iso.datetime(),
58
+ updatedAt: z.iso.datetime(),
59
+ /**
60
+ * Bucket hashes this deck's media resolved to when it was published, and the
61
+ * whole reason publish-time GC can protect them (ADR 0002).
62
+ *
63
+ * A deck references media by MANIFEST ID, and an id is only resolvable to a
64
+ * bucket hash while the manifest still carries the entry — which is exactly
65
+ * what stops being true in the case the keep-set exists for: the editor
66
+ * removes the media from the library, the entry goes, and the object then
67
+ * looks unreferenced to every reader. Resolving at publish time and recording
68
+ * the answer here is what survives that. Absent when the deck references no
69
+ * bucket-backed media, which is every deck until the media components land.
70
+ */
71
+ assetHashes: z.array(z.string().min(1)).optional(),
72
+ });
73
+
74
+ export type DeckOwner = z.infer<typeof DeckOwnerSchema>;
75
+ export type DeckMeta = z.infer<typeof DeckMetaSchema>;
76
+
77
+ export type DeckMetaParseResult = { ok: true; meta: DeckMeta } | { ok: false; errors: string[] };
78
+
79
+ /** Parse an untrusted meta sidecar, reporting every problem at once. */
80
+ export function parseDeckMeta(input: unknown): DeckMetaParseResult {
81
+ const result = DeckMetaSchema.safeParse(input);
82
+ return result.success ? { ok: true, meta: result.data } : { ok: false, errors: formatDeckIssues(result.error) };
83
+ }
84
+
85
+ /** Pretty-printed with a trailing newline, matching every other repo-stored JSON. */
86
+ export function serializeDeckMeta(meta: DeckMeta): string {
87
+ return JSON.stringify(meta, null, 2) + "\n";
88
+ }