@sanity-labs/slides 0.0.0

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 (224) hide show
  1. package/README.md +241 -0
  2. package/SKILL.md +119 -0
  3. package/dist/cli.d.ts +38 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +386 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/components.d.ts +179 -0
  8. package/dist/core/components.d.ts.map +1 -0
  9. package/dist/core/components.js +40 -0
  10. package/dist/core/components.js.map +1 -0
  11. package/dist/core/fake-runtime.d.ts +138 -0
  12. package/dist/core/fake-runtime.d.ts.map +1 -0
  13. package/dist/core/fake-runtime.js +210 -0
  14. package/dist/core/fake-runtime.js.map +1 -0
  15. package/dist/core/font-resolver.d.ts +28 -0
  16. package/dist/core/font-resolver.d.ts.map +1 -0
  17. package/dist/core/font-resolver.js +30 -0
  18. package/dist/core/font-resolver.js.map +1 -0
  19. package/dist/core/geometry.d.ts +71 -0
  20. package/dist/core/geometry.d.ts.map +1 -0
  21. package/dist/core/geometry.js +44 -0
  22. package/dist/core/geometry.js.map +1 -0
  23. package/dist/core/index.d.ts +19 -0
  24. package/dist/core/index.d.ts.map +1 -0
  25. package/dist/core/index.js +20 -0
  26. package/dist/core/index.js.map +1 -0
  27. package/dist/core/manifest.d.ts +123 -0
  28. package/dist/core/manifest.d.ts.map +1 -0
  29. package/dist/core/manifest.js +43 -0
  30. package/dist/core/manifest.js.map +1 -0
  31. package/dist/core/op-translator-pptx.d.ts +150 -0
  32. package/dist/core/op-translator-pptx.d.ts.map +1 -0
  33. package/dist/core/op-translator-pptx.js +245 -0
  34. package/dist/core/op-translator-pptx.js.map +1 -0
  35. package/dist/core/pptx-runtime.d.ts +103 -0
  36. package/dist/core/pptx-runtime.d.ts.map +1 -0
  37. package/dist/core/pptx-runtime.js +405 -0
  38. package/dist/core/pptx-runtime.js.map +1 -0
  39. package/dist/core/reconciler.d.ts +113 -0
  40. package/dist/core/reconciler.d.ts.map +1 -0
  41. package/dist/core/reconciler.js +453 -0
  42. package/dist/core/reconciler.js.map +1 -0
  43. package/dist/core/runtime.d.ts +161 -0
  44. package/dist/core/runtime.d.ts.map +1 -0
  45. package/dist/core/runtime.js +11 -0
  46. package/dist/core/runtime.js.map +1 -0
  47. package/dist/core/template.d.ts +32 -0
  48. package/dist/core/template.d.ts.map +1 -0
  49. package/dist/core/template.js +3 -0
  50. package/dist/core/template.js.map +1 -0
  51. package/dist/dev/auto-examples.d.ts +6 -0
  52. package/dist/dev/auto-examples.d.ts.map +1 -0
  53. package/dist/dev/auto-examples.js +79 -0
  54. package/dist/dev/auto-examples.js.map +1 -0
  55. package/dist/dev/bin/slides-dev.d.ts +3 -0
  56. package/dist/dev/bin/slides-dev.d.ts.map +1 -0
  57. package/dist/dev/bin/slides-dev.js +87 -0
  58. package/dist/dev/bin/slides-dev.js.map +1 -0
  59. package/dist/dev/bin/slides-dev.mjs +24 -0
  60. package/dist/dev/compose-deck.d.ts +18 -0
  61. package/dist/dev/compose-deck.d.ts.map +1 -0
  62. package/dist/dev/compose-deck.js +19 -0
  63. package/dist/dev/compose-deck.js.map +1 -0
  64. package/dist/dev/deck-viewer.d.ts +19 -0
  65. package/dist/dev/deck-viewer.d.ts.map +1 -0
  66. package/dist/dev/deck-viewer.js +237 -0
  67. package/dist/dev/deck-viewer.js.map +1 -0
  68. package/dist/dev/dev-server/client/entry.d.ts +2 -0
  69. package/dist/dev/dev-server/client/entry.d.ts.map +1 -0
  70. package/dist/dev/dev-server/client/entry.js +12 -0
  71. package/dist/dev/dev-server/client/entry.js.map +1 -0
  72. package/dist/dev/dev-server/output.d.ts +8 -0
  73. package/dist/dev/dev-server/output.d.ts.map +1 -0
  74. package/dist/dev/dev-server/output.js +32 -0
  75. package/dist/dev/dev-server/output.js.map +1 -0
  76. package/dist/dev/dev-server/server-only-stub.d.ts +7 -0
  77. package/dist/dev/dev-server/server-only-stub.d.ts.map +1 -0
  78. package/dist/dev/dev-server/server-only-stub.js +12 -0
  79. package/dist/dev/dev-server/server-only-stub.js.map +1 -0
  80. package/dist/dev/dev-server/start.d.ts +14 -0
  81. package/dist/dev/dev-server/start.d.ts.map +1 -0
  82. package/dist/dev/dev-server/start.js +135 -0
  83. package/dist/dev/dev-server/start.js.map +1 -0
  84. package/dist/dev/index.d.ts +5 -0
  85. package/dist/dev/index.d.ts.map +1 -0
  86. package/dist/dev/index.js +5 -0
  87. package/dist/dev/index.js.map +1 -0
  88. package/dist/dev/lib/cn.d.ts +3 -0
  89. package/dist/dev/lib/cn.d.ts.map +1 -0
  90. package/dist/dev/lib/cn.js +3 -0
  91. package/dist/dev/lib/cn.js.map +1 -0
  92. package/dist/dev/slide-canvas.d.ts +12 -0
  93. package/dist/dev/slide-canvas.d.ts.map +1 -0
  94. package/dist/dev/slide-canvas.js +123 -0
  95. package/dist/dev/slide-canvas.js.map +1 -0
  96. package/dist/dev/styles.css +37 -0
  97. package/dist/dev/ui/icon-button.d.ts +12 -0
  98. package/dist/dev/ui/icon-button.d.ts.map +1 -0
  99. package/dist/dev/ui/icon-button.js +6 -0
  100. package/dist/dev/ui/icon-button.js.map +1 -0
  101. package/dist/dev/ui/kbd.d.ts +6 -0
  102. package/dist/dev/ui/kbd.d.ts.map +1 -0
  103. package/dist/dev/ui/kbd.js +4 -0
  104. package/dist/dev/ui/kbd.js.map +1 -0
  105. package/dist/dev/ui/text-button.d.ts +10 -0
  106. package/dist/dev/ui/text-button.d.ts.map +1 -0
  107. package/dist/dev/ui/text-button.js +6 -0
  108. package/dist/dev/ui/text-button.js.map +1 -0
  109. package/dist/dev/url-state.d.ts +7 -0
  110. package/dist/dev/url-state.d.ts.map +1 -0
  111. package/dist/dev/url-state.js +13 -0
  112. package/dist/dev/url-state.js.map +1 -0
  113. package/dist/dev/use-keyboard-nav.d.ts +17 -0
  114. package/dist/dev/use-keyboard-nav.d.ts.map +1 -0
  115. package/dist/dev/use-keyboard-nav.js +53 -0
  116. package/dist/dev/use-keyboard-nav.js.map +1 -0
  117. package/dist/index.d.ts +17 -0
  118. package/dist/index.d.ts.map +1 -0
  119. package/dist/index.js +17 -0
  120. package/dist/index.js.map +1 -0
  121. package/dist/mcp/errors.d.ts +57 -0
  122. package/dist/mcp/errors.d.ts.map +1 -0
  123. package/dist/mcp/errors.js +44 -0
  124. package/dist/mcp/errors.js.map +1 -0
  125. package/dist/mcp/index.d.ts +29 -0
  126. package/dist/mcp/index.d.ts.map +1 -0
  127. package/dist/mcp/index.js +29 -0
  128. package/dist/mcp/index.js.map +1 -0
  129. package/dist/mcp/naming.d.ts +37 -0
  130. package/dist/mcp/naming.d.ts.map +1 -0
  131. package/dist/mcp/naming.js +43 -0
  132. package/dist/mcp/naming.js.map +1 -0
  133. package/dist/mcp/render.d.ts +45 -0
  134. package/dist/mcp/render.d.ts.map +1 -0
  135. package/dist/mcp/render.js +77 -0
  136. package/dist/mcp/render.js.map +1 -0
  137. package/dist/mcp/schema.d.ts +54 -0
  138. package/dist/mcp/schema.d.ts.map +1 -0
  139. package/dist/mcp/schema.js +55 -0
  140. package/dist/mcp/schema.js.map +1 -0
  141. package/dist/mcp/server.d.ts +63 -0
  142. package/dist/mcp/server.d.ts.map +1 -0
  143. package/dist/mcp/server.js +196 -0
  144. package/dist/mcp/server.js.map +1 -0
  145. package/dist/scaffold/index.d.ts +39 -0
  146. package/dist/scaffold/index.d.ts.map +1 -0
  147. package/dist/scaffold/index.js +84 -0
  148. package/dist/scaffold/index.js.map +1 -0
  149. package/dist/scaffold/template-base/README.md +134 -0
  150. package/dist/scaffold/template-base/_gitignore +4 -0
  151. package/dist/scaffold/template-base/package.json +35 -0
  152. package/dist/scaffold/template-base/src/components/Cover.tsx +30 -0
  153. package/dist/scaffold/template-base/src/index.ts +27 -0
  154. package/dist/scaffold/template-base/src/preview.tsx +9 -0
  155. package/dist/scaffold/template-base/tsconfig.build.json +10 -0
  156. package/dist/scaffold/template-base/tsconfig.json +18 -0
  157. package/package.json +164 -0
  158. package/src/__tests__/fixtures/test-template/index.tsx +77 -0
  159. package/src/__tests__/pptx-mcp.test.ts +85 -0
  160. package/src/__tests__/pptx-smoke.test.ts +45 -0
  161. package/src/__tests__/preview.test.ts +28 -0
  162. package/src/cli.ts +426 -0
  163. package/src/core/__snapshots__/reconciler.test.ts.snap +320 -0
  164. package/src/core/components.test.ts +57 -0
  165. package/src/core/components.ts +196 -0
  166. package/src/core/fake-runtime.test.ts +174 -0
  167. package/src/core/fake-runtime.ts +302 -0
  168. package/src/core/font-resolver.ts +46 -0
  169. package/src/core/geometry.test.ts +58 -0
  170. package/src/core/geometry.ts +91 -0
  171. package/src/core/index.ts +69 -0
  172. package/src/core/manifest.test.ts +33 -0
  173. package/src/core/manifest.ts +150 -0
  174. package/src/core/op-translator-pptx.test.ts +204 -0
  175. package/src/core/op-translator-pptx.ts +365 -0
  176. package/src/core/pptx-runtime.test.ts +137 -0
  177. package/src/core/pptx-runtime.ts +504 -0
  178. package/src/core/reconciler.test.ts +644 -0
  179. package/src/core/reconciler.ts +603 -0
  180. package/src/core/runtime.ts +150 -0
  181. package/src/core/template.test.ts +136 -0
  182. package/src/core/template.ts +37 -0
  183. package/src/dev/auto-examples.ts +89 -0
  184. package/src/dev/bin/slides-dev.mjs +24 -0
  185. package/src/dev/bin/slides-dev.ts +101 -0
  186. package/src/dev/compose-deck.test.ts +68 -0
  187. package/src/dev/compose-deck.ts +40 -0
  188. package/src/dev/deck-viewer.tsx +677 -0
  189. package/src/dev/dev-server/client/entry.tsx +15 -0
  190. package/src/dev/dev-server/client/index.html +24 -0
  191. package/src/dev/dev-server/output.ts +37 -0
  192. package/src/dev/dev-server/server-only-stub.ts +12 -0
  193. package/src/dev/dev-server/start.ts +155 -0
  194. package/src/dev/index.ts +4 -0
  195. package/src/dev/lib/cn.ts +3 -0
  196. package/src/dev/slide-canvas.test.tsx +66 -0
  197. package/src/dev/slide-canvas.tsx +170 -0
  198. package/src/dev/styles.css +37 -0
  199. package/src/dev/ui/icon-button.tsx +31 -0
  200. package/src/dev/ui/kbd.tsx +20 -0
  201. package/src/dev/ui/text-button.tsx +31 -0
  202. package/src/dev/url-state.test.ts +22 -0
  203. package/src/dev/url-state.ts +17 -0
  204. package/src/dev/use-keyboard-nav.ts +64 -0
  205. package/src/index.ts +17 -0
  206. package/src/mcp/errors.test.ts +51 -0
  207. package/src/mcp/errors.ts +76 -0
  208. package/src/mcp/index.ts +45 -0
  209. package/src/mcp/naming.test.ts +39 -0
  210. package/src/mcp/naming.ts +49 -0
  211. package/src/mcp/render.ts +110 -0
  212. package/src/mcp/schema.test.ts +86 -0
  213. package/src/mcp/schema.ts +93 -0
  214. package/src/mcp/server.test.ts +309 -0
  215. package/src/mcp/server.ts +276 -0
  216. package/src/scaffold/index.ts +102 -0
  217. package/src/scaffold/template-base/README.md +134 -0
  218. package/src/scaffold/template-base/_gitignore +4 -0
  219. package/src/scaffold/template-base/package.json +35 -0
  220. package/src/scaffold/template-base/src/components/Cover.tsx +30 -0
  221. package/src/scaffold/template-base/src/index.ts +27 -0
  222. package/src/scaffold/template-base/src/preview.tsx +9 -0
  223. package/src/scaffold/template-base/tsconfig.build.json +10 -0
  224. package/src/scaffold/template-base/tsconfig.json +18 -0
@@ -0,0 +1,603 @@
1
+ /**
2
+ * The reconciler — walks a React element tree and emits a sequence of
3
+ * `SlideOp`s plus a generation manifest.
4
+ *
5
+ * # Why a custom walker (not `react-reconciler`)
6
+ *
7
+ * The natural reach is for the `react-reconciler` package — it's what
8
+ * react-three-fiber, react-pdf, and Ink use. But the constraints of this
9
+ * surface make a custom walker a strictly simpler fit:
10
+ *
11
+ * 1. **No state.** Template components are pure. No hooks, no effects, no refs.
12
+ * The whole reason to use `react-reconciler` is the scheduler + commit
13
+ * phases that make state-driven re-renders work. We don't have state.
14
+ * 2. **Write-once.** A `renderToOps()` call produces one batch and exits.
15
+ * There's no incremental update model to honor.
16
+ * 3. **No host node mutation.** Output is an immutable op list. The 30+ host-
17
+ * config methods `react-reconciler` requires (`appendChild`,
18
+ * `removeChild`, `commitMount`, etc.) all collapse to "push to an array."
19
+ * 4. **Tree shape is shallow and known.** `Slide` → `Box` | `Image` →
20
+ * text runs. The walker can encode this structure directly with much
21
+ * better error messages than a generic reconciler would surface.
22
+ *
23
+ * If we ever add state-driven re-renders (probably never; the docs commit to
24
+ * forward-only generation), we can swap in `react-reconciler` behind the same
25
+ * `renderToOps` signature. The signature is the contract.
26
+ *
27
+ * # What the walker does
28
+ *
29
+ * 1. Resolve function components by invoking them with their props (no hooks).
30
+ * 2. Flatten fragments and arrays.
31
+ * 3. For each `<Slide>`, emit `createSlide`, then walk its children for
32
+ * `<Box>`s and `<Image>`s.
33
+ * 4. For each `<Box>`, emit `createShape` (TEXT_BOX), then — if `fill` is
34
+ * set — `updateShapeProperties` carrying the resolved fill color, then
35
+ * collect its text runs into a single `insertText` + per-run
36
+ * `updateTextStyle` calls.
37
+ * 5. For each `<Image>`, emit `createImage` with the resolved URL and (if
38
+ * no slotId is set) the user-supplied alt-text. Record the artifact
39
+ * reference in the manifest, deduped by identifier.
40
+ *
41
+ * Errors throw with paths like "Slide[0] > Box[1] > unexpected child" so the
42
+ * brand-component author has enough information to localize the problem.
43
+ *
44
+ * # Determinism
45
+ *
46
+ * Object IDs are generated from a counter seeded per call. Same input tree →
47
+ * same output ops → same snapshot. This is what makes layer-2 golden tests in
48
+ * `docs/testing-strategy.md` work.
49
+ */
50
+
51
+ import {
52
+ Children,
53
+ Fragment,
54
+ isValidElement,
55
+ type FunctionComponent,
56
+ type ReactElement,
57
+ type ReactNode,
58
+ } from 'react';
59
+ import {
60
+ Box,
61
+ Color,
62
+ Text,
63
+ Image,
64
+ Slide,
65
+ isPrimitive,
66
+ type BoxFill,
67
+ type BoxProps,
68
+ type ColorProps,
69
+ type TextProps,
70
+ type ImageProps,
71
+ type SlideProps,
72
+ } from './components.js';
73
+ import { isFontRole, resolveFontRole } from './font-resolver.js';
74
+ import { ptToEmu, type Rect } from './geometry.js';
75
+ import type { ArtifactRef, GenerationManifest, ReconcileResult, SlotId } from './manifest.js';
76
+ import type { Template } from './template.js';
77
+ import type { EmuRect, ShapeProperties, SlideOp, TextRange, TextStyle } from './runtime.js';
78
+
79
+ /** Inputs to a single render. */
80
+ export interface RenderToOpsInput {
81
+ /** The React element tree. Top-level should be a `<Slide>` or a fragment of `<Slide>`s. */
82
+ readonly tree: ReactNode;
83
+ /** The template whose tokens are in scope. */
84
+ readonly template: Template;
85
+ /**
86
+ * Target deck ID (re-fill case) or `null` for a new deck.
87
+ *
88
+ * Ops emission doesn't differ between the two today, but the manifest
89
+ * records it, and downstream runtime adapters may use it (e.g., to skip
90
+ * `createSlide` if the deck is being re-filled rather than rebuilt).
91
+ */
92
+ readonly deckId: string | null;
93
+ /**
94
+ * Template artifacts the deck references *in addition to* any artifacts the
95
+ * reconciler itself records by walking `<Image>` primitives. Provided by
96
+ * brand-component code that resolved textures/logos/etc. before invoking
97
+ * the reconciler. The reconciler merges these with the artifacts it
98
+ * discovers, deduping by identifier (last-wins on duplicates within either
99
+ * source).
100
+ */
101
+ readonly artifacts?: readonly ArtifactRef[];
102
+ /**
103
+ * Override for the manifest's `generatedAt` timestamp. Tests pin this to
104
+ * keep snapshots deterministic.
105
+ */
106
+ readonly now?: () => string;
107
+ }
108
+
109
+ /** Internal mutable state carried through the walk. */
110
+ interface WalkContext {
111
+ readonly template: Template;
112
+ readonly ops: SlideOp[];
113
+ readonly slots: Map<SlotId, string>;
114
+ /**
115
+ * Template artifacts discovered while walking, keyed by identifier so a single
116
+ * texture or logo referenced from multiple slides only appears once in the
117
+ * manifest. Insertion order is preserved by the underlying Map.
118
+ */
119
+ readonly artifacts: Map<string, ArtifactRef>;
120
+ /** Counter for generating unique slide / shape / image IDs. */
121
+ idCounter: number;
122
+ /** The slide currently being walked, for error context. */
123
+ currentSlideIndex: number;
124
+ /** The box being walked (if any), for error context. */
125
+ currentBoxIndex: number;
126
+ }
127
+
128
+ /**
129
+ * Render a React tree to ops + manifest.
130
+ *
131
+ * The function is synchronous: function components are invoked inline, and the
132
+ * walk is purely structural. There's no scheduling, no async boundaries.
133
+ */
134
+ export const renderToOps = (input: RenderToOpsInput): ReconcileResult => {
135
+ const ctx: WalkContext = {
136
+ template: input.template,
137
+ ops: [],
138
+ slots: new Map(),
139
+ artifacts: new Map(),
140
+ idCounter: 0,
141
+ currentSlideIndex: -1,
142
+ currentBoxIndex: -1,
143
+ };
144
+
145
+ // Seed with caller-supplied artifacts first so reconciler-discovered
146
+ // artifacts on the same identifier overwrite (more recent / context-bound
147
+ // resolution wins). Insertion order is preserved.
148
+ if (input.artifacts !== undefined) {
149
+ for (const artifact of input.artifacts) {
150
+ ctx.artifacts.set(artifact.identifier, artifact);
151
+ }
152
+ }
153
+
154
+ const slides = collectSlides(input.tree, ctx);
155
+ slides.forEach((slide, index) => {
156
+ ctx.currentSlideIndex = index;
157
+ ctx.currentBoxIndex = -1;
158
+ walkSlide(slide, index, ctx);
159
+ });
160
+
161
+ const manifest: GenerationManifest = {
162
+ manifestVersion: '1',
163
+ generatedBy: 'react-pptx',
164
+ generatedAt: (input.now ?? defaultNow)(),
165
+ templateName: input.template.name,
166
+ deckId: input.deckId,
167
+ slots: Object.fromEntries(ctx.slots) as Record<SlotId, string>,
168
+ artifacts: [...ctx.artifacts.values()],
169
+ };
170
+
171
+ return { ops: ctx.ops, manifest };
172
+ };
173
+
174
+ const defaultNow = (): string => new Date().toISOString();
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Tree walk
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Resolve a node to its concrete element form, invoking function components.
182
+ *
183
+ * Returns:
184
+ * - An array of resolved primitives if the node is a fragment / array / function
185
+ * component that returned multiple children.
186
+ * - A single primitive element if the node resolves to one.
187
+ * - Empty array for falsy / boolean / null nodes (React's "render nothing").
188
+ *
189
+ * This is what makes `<Cover/>` work: `Cover` is a function component that
190
+ * returns `<Slide>...</Slide>`, so resolving it yields the underlying Slide.
191
+ */
192
+ const resolveNode = (node: ReactNode, ctx: WalkContext): ReactElement[] => {
193
+ if (node === null || node === undefined || node === false || node === true) {
194
+ return [];
195
+ }
196
+ if (typeof node === 'string' || typeof node === 'number') {
197
+ throw new ReconcilerError(
198
+ `Unexpected text node "${node}" at top level — text must live inside a <Box>.`,
199
+ ctx,
200
+ );
201
+ }
202
+ if (Array.isArray(node)) {
203
+ return node.flatMap((child) => resolveNode(child, ctx));
204
+ }
205
+ if (!isValidElement(node)) {
206
+ throw new ReconcilerError(`Unsupported child of type ${typeof node}.`, ctx);
207
+ }
208
+
209
+ // React.Fragment: descend into children.
210
+ if (node.type === Fragment) {
211
+ const props = node.props as { children?: ReactNode };
212
+ return resolveNode(props.children, ctx);
213
+ }
214
+
215
+ // Primitive host element: pass through.
216
+ if (isPrimitive(node.type)) {
217
+ return [node];
218
+ }
219
+
220
+ // Function component: invoke with its props (no hooks; brand components are pure).
221
+ if (typeof node.type === 'function') {
222
+ const Component = node.type as FunctionComponent<unknown>;
223
+ const result = invokeComponent(Component, node.props, ctx);
224
+ return resolveNode(result, ctx);
225
+ }
226
+
227
+ // Class components, intrinsic strings, etc.: not supported.
228
+ const typeName = describeType(node.type);
229
+ throw new ReconcilerError(`Unsupported element type ${typeName}.`, ctx);
230
+ };
231
+
232
+ const collectSlides = (tree: ReactNode, ctx: WalkContext): ReactElement[] => {
233
+ const resolved = resolveNode(tree, ctx);
234
+ for (const el of resolved) {
235
+ if (el.type !== Slide) {
236
+ throw new ReconcilerError(
237
+ `Top-level children must be <Slide> elements; got <${describeType(el.type)}>.`,
238
+ ctx,
239
+ );
240
+ }
241
+ }
242
+ return resolved;
243
+ };
244
+
245
+ const walkSlide = (slide: ReactElement, index: number, ctx: WalkContext): void => {
246
+ const props = slide.props as SlideProps;
247
+ const slideId = makeId('slide', ctx);
248
+ ctx.ops.push({
249
+ type: 'createSlide',
250
+ slideId,
251
+ insertAt: index,
252
+ });
253
+
254
+ const children = resolveNode(props.children, ctx);
255
+ children.forEach((child, childIndex) => {
256
+ ctx.currentBoxIndex = childIndex;
257
+ if (child.type === Box) {
258
+ walkBox(child, slideId, ctx);
259
+ return;
260
+ }
261
+ if (child.type === Image) {
262
+ walkImage(child, slideId, ctx);
263
+ return;
264
+ }
265
+ throw new ReconcilerError(
266
+ `<Slide> children must be <Box> or <Image> elements; got <${describeType(child.type)}>.`,
267
+ ctx,
268
+ );
269
+ });
270
+ };
271
+
272
+ const walkBox = (box: ReactElement, slideId: string, ctx: WalkContext): void => {
273
+ const props = box.props as BoxProps;
274
+ const shapeId = makeId('shape', ctx);
275
+ ctx.ops.push({
276
+ type: 'createShape',
277
+ slideId,
278
+ shapeId,
279
+ shape: 'TEXT_BOX',
280
+ rect: rectToEmu(props.rect),
281
+ });
282
+
283
+ // Fill applies to the shape itself, before any text. Order matters:
284
+ // createShape → updateShapeProperties → insertText → updateTextStyle
285
+ // This way an empty Box with a fill is a valid full-bleed colored
286
+ // background, and a Box with both fill and text gets the fill behind the
287
+ // text without any z-order ambiguity.
288
+ if (props.fill !== undefined) {
289
+ const properties = boxFillToShapeProperties(props.fill);
290
+ ctx.ops.push({
291
+ type: 'updateShapeProperties',
292
+ objectId: shapeId,
293
+ properties,
294
+ });
295
+ }
296
+
297
+ if (props.slotId !== undefined) {
298
+ if (ctx.slots.has(props.slotId)) {
299
+ throw new ReconcilerError(
300
+ `Duplicate slotId "${props.slotId}" — slot IDs must be unique within a deck.`,
301
+ ctx,
302
+ );
303
+ }
304
+ ctx.slots.set(props.slotId, shapeId);
305
+ // Slot identity is encoded into shape alt-text per generation-model.md.
306
+ // We re-use `updateShapeProperties` semantics by piggybacking through a
307
+ // dedicated op-shape — here, alt-text is set via a property update at the
308
+ // runtime layer. The op carries no fillColor/outlineColor, signalling
309
+ // "alt text only." If alt-text ever deserves its own op type, this is the
310
+ // keeps the SlideOp union narrow.
311
+ //
312
+ // NOTE: `updateShapeProperties` doesn't currently carry alt-text; the
313
+ // runtime adapter resolves slot alt-text from the manifest's `slots` map,
314
+ // not from an op. The manifest is the source of truth for slot identity.
315
+ // This is intentional: ops express "what to do," manifest expresses "what
316
+ // is true after." Encoding alt-text into ops would duplicate the manifest
317
+ // entry.
318
+ }
319
+
320
+ // Default styles to apply across the whole shape after text is inserted.
321
+ const text = collectTextRuns(props.children, ctx);
322
+ if (text.runs.length === 0) {
323
+ // Empty Box is permitted (colored rectangle, full-bleed background).
324
+ return;
325
+ }
326
+
327
+ ctx.ops.push({ type: 'insertText', objectId: shapeId, text: text.full });
328
+
329
+ if (props.textStyle !== undefined && Object.keys(props.textStyle).length > 0) {
330
+ ctx.ops.push({
331
+ type: 'updateTextStyle',
332
+ objectId: shapeId,
333
+ range: { start: 0, end: text.full.length },
334
+ style: resolveTextStyleFonts(props.textStyle, ctx.template, ctx),
335
+ });
336
+ }
337
+
338
+ if (props.paragraphStyle !== undefined && Object.keys(props.paragraphStyle).length > 0) {
339
+ ctx.ops.push({
340
+ type: 'updateParagraphStyle',
341
+ objectId: shapeId,
342
+ range: { start: 0, end: text.full.length },
343
+ style: props.paragraphStyle,
344
+ });
345
+ }
346
+
347
+ for (const run of text.runs) {
348
+ if (Object.keys(run.style).length === 0) continue;
349
+ ctx.ops.push({
350
+ type: 'updateTextStyle',
351
+ objectId: shapeId,
352
+ range: run.range,
353
+ style: resolveTextStyleFonts(run.style, ctx.template, ctx),
354
+ });
355
+ }
356
+ };
357
+
358
+ /**
359
+ * Walk an `<Image>` element: emit a `createImage` op and record the artifact.
360
+ *
361
+ * Slot/altText interaction: the reconciler emits `createImage` with the
362
+ * user-supplied `altText` (if any) — that's what lands in the FakeRuntime's
363
+ * in-memory model and what the live runtime's translator will set as the
364
+ * page-element description initially. If a `slotId` is also set, the
365
+ * runtime adapter's slot-stamping post-pass
366
+ * (`op-translator.slotRegistryToAltTextRequests`) overwrites the live
367
+ * page-element alt-text with the slot tag, because slot identity is required
368
+ * for re-fill and wins. The op stream still carries the user altText, so the
369
+ * substrate doesn't lose it.
370
+ */
371
+ const walkImage = (image: ReactElement, slideId: string, ctx: WalkContext): void => {
372
+ const props = image.props as ImageProps;
373
+ const imageId = makeId('image', ctx);
374
+ ctx.ops.push({
375
+ type: 'createImage',
376
+ slideId,
377
+ imageId,
378
+ url: props.image.url,
379
+ rect: rectToEmu(props.rect),
380
+ ...(props.altText !== undefined ? { altText: props.altText } : {}),
381
+ });
382
+
383
+ // Dedup by identifier: a logo or texture referenced from many slides should
384
+ // appear in manifest.artifacts exactly once. Last-wins on conflicts (later
385
+ // resolution overrides earlier) — typically a no-op since the brand
386
+ // resolver is deterministic per identifier.
387
+ ctx.artifacts.set(props.image.artifact.identifier, props.image.artifact);
388
+
389
+ if (props.slotId !== undefined) {
390
+ if (ctx.slots.has(props.slotId)) {
391
+ throw new ReconcilerError(
392
+ `Duplicate slotId "${props.slotId}" — slot IDs must be unique within a deck.`,
393
+ ctx,
394
+ );
395
+ }
396
+ ctx.slots.set(props.slotId, imageId);
397
+ }
398
+ };
399
+
400
+ /**
401
+ * Resolve a `BoxFill` discriminated union to the shape-properties subset the
402
+ * reconciler emits via `updateShapeProperties`.
403
+ *
404
+ * The discriminant (`kind`) is exhaustively switched so adding a new fill
405
+ * variant in `components.ts` (e.g., `'texture'`) is a compile-time prompt to
406
+ * extend this resolver as well.
407
+ */
408
+ const boxFillToShapeProperties = (fill: BoxFill): ShapeProperties => {
409
+ switch (fill.kind) {
410
+ case 'solid':
411
+ return { fillColor: fill.color };
412
+ }
413
+ };
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Text run collection
417
+ // ---------------------------------------------------------------------------
418
+
419
+ interface CollectedText {
420
+ /** The full concatenated text for the box. */
421
+ full: string;
422
+ /** Per-run style spans, in document order. */
423
+ runs: Array<{ range: TextRange; style: TextStyle }>;
424
+ }
425
+
426
+ /**
427
+ * Recursively collect text runs from a Box's children.
428
+ *
429
+ * Rules:
430
+ * - Strings/numbers contribute raw text with no style.
431
+ * - `<Text>` nests its own style and contributes its children's text.
432
+ * - `<Color>` is sugar for `<Text textStyle={{ foregroundColor }}>`.
433
+ * - Nested function components are resolved (so `<Bullet>foo</Bullet>` works
434
+ * if `Bullet` is a brand component returning `<Text>foo</Text>`).
435
+ * - Anything else is an error.
436
+ */
437
+ const collectTextRuns = (node: ReactNode, ctx: WalkContext): CollectedText => {
438
+ const acc: CollectedText = { full: '', runs: [] };
439
+ const append = (text: string, style: TextStyle): void => {
440
+ if (text.length === 0) return;
441
+ const start = acc.full.length;
442
+ acc.full += text;
443
+ acc.runs.push({ range: { start, end: start + text.length }, style });
444
+ };
445
+ walkText(node, {}, append, ctx);
446
+ return acc;
447
+ };
448
+
449
+ const walkText = (
450
+ node: ReactNode,
451
+ inheritedStyle: TextStyle,
452
+ append: (text: string, style: TextStyle) => void,
453
+ ctx: WalkContext,
454
+ ): void => {
455
+ if (node === null || node === undefined || node === false || node === true) {
456
+ return;
457
+ }
458
+ if (typeof node === 'string') {
459
+ append(node, inheritedStyle);
460
+ return;
461
+ }
462
+ if (typeof node === 'number') {
463
+ append(String(node), inheritedStyle);
464
+ return;
465
+ }
466
+ if (Array.isArray(node)) {
467
+ Children.forEach(node, (child) => walkText(child, inheritedStyle, append, ctx));
468
+ return;
469
+ }
470
+ if (!isValidElement(node)) {
471
+ throw new ReconcilerError(`Unsupported text child of type ${typeof node}.`, ctx);
472
+ }
473
+
474
+ if (node.type === Fragment) {
475
+ const props = node.props as { children?: ReactNode };
476
+ walkText(props.children, inheritedStyle, append, ctx);
477
+ return;
478
+ }
479
+
480
+ if (node.type === Text) {
481
+ const props = node.props as TextProps;
482
+ const merged: TextStyle = { ...inheritedStyle, ...(props.textStyle ?? {}) };
483
+ walkText(props.children, merged, append, ctx);
484
+ return;
485
+ }
486
+
487
+ if (node.type === Color) {
488
+ const props = node.props as ColorProps;
489
+ const merged: TextStyle = { ...inheritedStyle, foregroundColor: props.color };
490
+ walkText(props.children, merged, append, ctx);
491
+ return;
492
+ }
493
+
494
+ if (node.type === Slide || node.type === Box || node.type === Image) {
495
+ throw new ReconcilerError(`<${describeType(node.type)}> cannot appear inside a <Box>.`, ctx);
496
+ }
497
+
498
+ if (typeof node.type === 'function') {
499
+ const Component = node.type as FunctionComponent<unknown>;
500
+ const result = invokeComponent(Component, node.props, ctx);
501
+ walkText(result, inheritedStyle, append, ctx);
502
+ return;
503
+ }
504
+
505
+ throw new ReconcilerError(`Unsupported text element <${describeType(node.type)}>.`, ctx);
506
+ };
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // Helpers
510
+ // ---------------------------------------------------------------------------
511
+
512
+ /**
513
+ * Resolve any role-string `fontFamily` in a `TextStyle` to a literal family
514
+ * name using the brand's font stack. Returns the same object reference if the
515
+ * style has no role-string `fontFamily` (the common case for literal-family
516
+ * passes — preserves snapshot stability and avoids unnecessary allocation).
517
+ *
518
+ * Resolution semantics (no backend querying):
519
+ * role keyword in → `brand.fonts[role][0]` out (the brand's first preference).
520
+ *
521
+ * If the brand declares an empty stack for the requested role, this throws
522
+ * with a clear "brand defines no <role> font" error. Strict-fail beats
523
+ * silent-Arial when the source of truth is misconfigured.
524
+ *
525
+ * The reconciler invokes this immediately before pushing every text-style op,
526
+ * so downstream translators never see a role keyword as a literal family.
527
+ */
528
+ const resolveTextStyleFonts = (
529
+ style: TextStyle,
530
+ template: Template,
531
+ ctx: WalkContext,
532
+ ): TextStyle => {
533
+ const family = style.fontFamily;
534
+ if (family === undefined) return style;
535
+ if (!isFontRole(family)) return style; // literal family — pass through.
536
+ try {
537
+ return { ...style, fontFamily: resolveFontRole(template.fonts, family) };
538
+ } catch (err) {
539
+ if (err instanceof Error && err.name === 'EmptyFontStackError') {
540
+ throw new ReconcilerError(
541
+ `Template "${template.name}" defines no ${family} font (fonts.${family} is empty). Every role needs at least one entry, with a system-safe last entry (e.g., "Arial").`,
542
+ ctx,
543
+ );
544
+ }
545
+ throw err;
546
+ }
547
+ };
548
+
549
+ const rectToEmu = (rect: Rect): EmuRect => ({
550
+ x: ptToEmu(rect.x),
551
+ y: ptToEmu(rect.y),
552
+ w: ptToEmu(rect.w),
553
+ h: ptToEmu(rect.h),
554
+ });
555
+
556
+ const makeId = (prefix: string, ctx: WalkContext): string => {
557
+ ctx.idCounter += 1;
558
+ return `${prefix}_${ctx.idCounter}`;
559
+ };
560
+
561
+ /**
562
+ * Invoke a function component with its props and return its rendered children.
563
+ *
564
+ * Async components are not supported. React 19 widened the function-component
565
+ * return type to `ReactNode | Promise<ReactNode>`; we narrow back to
566
+ * `ReactNode` here and throw if a Promise is returned.
567
+ */
568
+ const invokeComponent = (
569
+ Component: FunctionComponent<unknown>,
570
+ props: unknown,
571
+ ctx: WalkContext,
572
+ ): ReactNode => {
573
+ const result = Component(props) as ReactNode | Promise<ReactNode>;
574
+ if (typeof result === 'object' && result !== null && 'then' in result) {
575
+ throw new ReconcilerError(
576
+ `<${describeType(Component)}> returned a Promise. Template components must be synchronous (no async, no Suspense).`,
577
+ ctx,
578
+ );
579
+ }
580
+ return result;
581
+ };
582
+
583
+ const describeType = (type: unknown): string => {
584
+ if (typeof type === 'string') return type;
585
+ if (typeof type === 'function') {
586
+ const named = type as { displayName?: string; name?: string };
587
+ return named.displayName ?? named.name ?? 'anonymous';
588
+ }
589
+ if (type === Fragment) return 'Fragment';
590
+ return String(type);
591
+ };
592
+
593
+ /** Error thrown by the reconciler with location info pre-formatted. */
594
+ export class ReconcilerError extends Error {
595
+ constructor(message: string, ctx: Pick<WalkContext, 'currentSlideIndex' | 'currentBoxIndex'>) {
596
+ const path: string[] = [];
597
+ if (ctx.currentSlideIndex >= 0) path.push(`Slide[${ctx.currentSlideIndex}]`);
598
+ if (ctx.currentBoxIndex >= 0) path.push(`Box[${ctx.currentBoxIndex}]`);
599
+ const prefix = path.length > 0 ? `${path.join(' > ')}: ` : '';
600
+ super(`${prefix}${message}`);
601
+ this.name = 'ReconcilerError';
602
+ }
603
+ }