@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,150 @@
1
+ /**
2
+ * The seam between the reconciler and the slide runtime backend.
3
+ *
4
+ * Production wires `PptxSlidesRuntime` into the reconciler. Tests wire
5
+ * `FakeSlidesRuntime` which records every operation and builds an in-memory
6
+ * deck model.
7
+ *
8
+ * See `docs/testing-strategy.md` for the rationale.
9
+ */
10
+
11
+ import type { Emu } from './geometry.js';
12
+ import type { GenerationManifest } from './manifest.js';
13
+
14
+ /**
15
+ * A typed Slides API operation the reconciler emits.
16
+ *
17
+ * This is intentionally NOT a 1:1 mapping of any specific slide API — it's
18
+ * the *intent* the reconciler emits, with EMU and IDs already resolved. The
19
+ * runtime adapter turns these into backend-specific operations.
20
+ *
21
+ * Keeping ops at this layer (vs raw API requests) means:
22
+ * 1. Goldens read clearly. `{ type: "createShape", ... }` beats a 50-line nested object.
23
+ * 2. The API can evolve underneath without churning every test snapshot.
24
+ * 3. Template-agnostic; nothing here is Sanity-specific.
25
+ */
26
+ export type SlideOp =
27
+ | { type: 'createSlide'; slideId: string; insertAt?: number }
28
+ | { type: 'createShape'; slideId: string; shapeId: string; shape: ShapeKind; rect: EmuRect }
29
+ | { type: 'insertText'; objectId: string; text: string }
30
+ | { type: 'updateTextStyle'; objectId: string; range: TextRange; style: TextStyle }
31
+ | { type: 'updateParagraphStyle'; objectId: string; range: TextRange; style: ParagraphStyle }
32
+ | {
33
+ type: 'createImage';
34
+ slideId: string;
35
+ imageId: string;
36
+ url: string;
37
+ rect: EmuRect;
38
+ altText?: string;
39
+ }
40
+ | { type: 'updateShapeProperties'; objectId: string; properties: ShapeProperties };
41
+
42
+ /** A rectangle in EMU — the reconciler converts pt → EMU before emitting. */
43
+ export interface EmuRect {
44
+ x: Emu;
45
+ y: Emu;
46
+ w: Emu;
47
+ h: Emu;
48
+ }
49
+
50
+ /**
51
+ * Shape kinds the reconciler emits. Names mirror the Office Open XML /
52
+ * Google Slides API enum so translators on either side can pass through
53
+ * without a lookup table.
54
+ */
55
+ export type ShapeKind = 'TEXT_BOX' | 'RECTANGLE' | 'ELLIPSE' | 'LINE';
56
+
57
+ /** Inclusive-exclusive index range over a string of text. */
58
+ export interface TextRange {
59
+ start: number;
60
+ end: number;
61
+ }
62
+
63
+ /**
64
+ * A font role keyword. Resolved against `Template.fonts[role][0]` at the
65
+ * reconciler boundary; the role keywords (`'display'` / `'body'` / `'mono'`)
66
+ * are reserved — a brand cannot have a literal font family named "display."
67
+ *
68
+ * See `reconciler.ts` for resolution semantics; the role keywords mirror
69
+ * `FontStack` keys so adding a role to one ripples to the other.
70
+ */
71
+ export type FontRole = 'display' | 'body' | 'mono';
72
+
73
+ /** Text-style properties supported at the reconciler boundary. */
74
+ export interface TextStyle {
75
+ /**
76
+ * Font family. Either:
77
+ * - A role keyword (`'display'` / `'body'` / `'mono'`), resolved against
78
+ * `Template.fonts[role][0]` at the reconciler boundary before the op is
79
+ * emitted. This is what brand components and `<Text/>` consumers
80
+ * should pass — it keeps the brand's font choice load-bearing.
81
+ * - A literal family name (e.g., `'Geist'`, `'Inter'`), passed through
82
+ * verbatim. Template authors with a specific family in mind use this.
83
+ *
84
+ * The role keywords are reserved — they shadow any literal family of the
85
+ * same name. If a future brand needed a literal `'display'` family, the
86
+ * shape would need to migrate to a discriminated union.
87
+ *
88
+ * Why a string union (vs. discriminated): the role keywords are vanishingly
89
+ * unlikely to ever be a real family; a discriminated union would force every
90
+ * literal-family consumer to write `{ kind: 'family', family: ... }` for no
91
+ * win. `flatten-for-brand.ts:inferFontRole` returns role strings directly,
92
+ * which compose cleanly into this shape.
93
+ */
94
+ fontFamily?: FontRole | string;
95
+ fontSize?: number; // pt — passes straight through, NOT converted to EMU
96
+ bold?: boolean;
97
+ italic?: boolean;
98
+ underline?: boolean;
99
+ foregroundColor?: HexColor;
100
+ backgroundColor?: HexColor;
101
+ }
102
+
103
+ /** Paragraph-style properties supported at the reconciler boundary. */
104
+ export interface ParagraphStyle {
105
+ alignment?: 'START' | 'CENTER' | 'END' | 'JUSTIFIED';
106
+ lineSpacing?: number; // multiplier (e.g., 1.2)
107
+ spaceAbove?: number; // pt
108
+ spaceBelow?: number; // pt
109
+ }
110
+
111
+ /** Shape-level visual properties. */
112
+ export interface ShapeProperties {
113
+ fillColor?: HexColor;
114
+ outlineColor?: HexColor;
115
+ outlineWeight?: number; // pt
116
+ }
117
+
118
+ /** A 24-bit hex color, e.g., "#FF5500". The reconciler converts to RGB at emit. */
119
+ export type HexColor = `#${string}`;
120
+
121
+ /**
122
+ * The runtime contract every slide backend satisfies. This is the seam that
123
+ * makes layered testing work — see `docs/testing-strategy.md`.
124
+ */
125
+ export interface SlidesRuntime {
126
+ /** Apply a sequence of slide operations to a deck. */
127
+ applyOps(deckId: string, ops: readonly SlideOp[]): Promise<ApplyOpsResult>;
128
+
129
+ /**
130
+ * Create a new deck, optionally seeded from a template reference.
131
+ *
132
+ * `masterRef` is opaque to the substrate; runtimes that don't have a
133
+ * master-template concept ignore it and initialize a blank deck.
134
+ */
135
+ createDeckFromMaster(masterRef: string, title: string): Promise<{ deckId: string }>;
136
+
137
+ /** Write the deck to a file on disk. Returns the absolute path. */
138
+ write(deckId: string): Promise<{ filePath: string }>;
139
+
140
+ /** Attach a manifest to a deck (for later retrieval). */
141
+ attachManifest(deckId: string, manifest: GenerationManifest): void;
142
+ }
143
+
144
+ /** What the runtime returns after applying a batch of ops. */
145
+ export interface ApplyOpsResult {
146
+ /** IDs of objects created during this batch, keyed by the requested ID. */
147
+ createdObjectIds: Readonly<Record<string, string>>;
148
+ /** Revision token for optimistic-concurrency on the next call. */
149
+ revisionId?: string;
150
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { z } from 'zod';
3
+ import type { Template, TemplateComponent } from './template.js';
4
+ import { defineTemplateComponent } from './template.js';
5
+ import { CANVAS_16_9 } from './geometry.js';
6
+
7
+ // A minimal valid brand for typing-level smoke tests. Real brands live downstream.
8
+ const TestBrand: Template = {
9
+ name: 'test',
10
+ canvas: CANVAS_16_9,
11
+ fonts: {
12
+ display: ['Inter', 'Arial'],
13
+ body: ['Inter', 'Arial'],
14
+ mono: ['Courier New'],
15
+ },
16
+ colors: {
17
+ 'fg.base': '#0b0b0b',
18
+ 'bg.surface': '#ffffff',
19
+ },
20
+ typography: {
21
+ 'body-md': { fontFamily: 'body', fontSize: 18, lineHeight: 1.5 },
22
+ },
23
+ spacing: {
24
+ sm: 8,
25
+ md: 12,
26
+ lg: 24,
27
+ },
28
+ components: {
29
+ Cover: {
30
+ component: () => null,
31
+ schema: z.object({ title: z.string() }).strict(),
32
+ description: 'Use as the first slide. Sets title and stance for the deck.',
33
+ } satisfies TemplateComponent<{ title: string }>,
34
+ },
35
+ };
36
+
37
+ describe('Template interface', () => {
38
+ test('a minimal brand satisfies the type', () => {
39
+ expect(TestBrand.name).toBe('test');
40
+ expect(TestBrand.canvas.w).toBe(960);
41
+ expect(Object.keys(TestBrand.components)).toEqual(['Cover']);
42
+ });
43
+
44
+ test('component schemas are strict', () => {
45
+ const cover = TestBrand.components['Cover'];
46
+ expect(cover).toBeDefined();
47
+ if (!cover) return;
48
+ expect(() => cover.schema.parse({ title: 'Hello' })).not.toThrow();
49
+ // .strict() rejects unknown keys
50
+ expect(() => cover.schema.parse({ title: 'Hello', extra: true })).toThrow();
51
+ });
52
+
53
+ test('token surfaces are populated and queryable', () => {
54
+ expect(TestBrand.colors['fg.base']).toBe('#0b0b0b');
55
+ expect(TestBrand.typography['body-md']?.fontSize).toBe(18);
56
+ expect(TestBrand.spacing['md']).toBe(12);
57
+ });
58
+
59
+ test('typography tokens reference font roles, not concrete font names', () => {
60
+ // The brand's typography references font *roles* (display/body/mono) which
61
+ // are resolved against `fonts` at runtime — see font-resolver. This means
62
+ // a token like body-md doesn't bake "Inter" into typography; it says
63
+ // "whatever the body font resolves to."
64
+ const token = TestBrand.typography['body-md'];
65
+ expect(token).toBeDefined();
66
+ if (!token) return;
67
+ expect(['display', 'body', 'mono']).toContain(token.fontFamily);
68
+ });
69
+ });
70
+
71
+ describe('defineTemplateComponent', () => {
72
+ const CoverSchema = z.object({ title: z.string() }).strict();
73
+ const TwoColumnSchema = z.object({ left: z.string(), right: z.string() }).strict();
74
+
75
+ const CoverComponent = (_props: { title: string }) => null;
76
+ const TwoColumnComponent = (_props: { left: string; right: string }) => null;
77
+
78
+ test('returns its input unchanged at runtime (identity)', () => {
79
+ const spec: TemplateComponent<{ title: string }> = {
80
+ component: CoverComponent,
81
+ schema: CoverSchema,
82
+ description: 'Use as the first slide. Sets title and stance.',
83
+ };
84
+ const result = defineTemplateComponent(spec);
85
+ expect(result).toBe(spec);
86
+ });
87
+
88
+ test('preserves component and schema fields', () => {
89
+ const spec = defineTemplateComponent({
90
+ component: CoverComponent,
91
+ schema: CoverSchema,
92
+ description: 'Use as the first slide.',
93
+ });
94
+ expect(spec.component).toBe(CoverComponent);
95
+ expect(spec.schema).toBe(CoverSchema);
96
+ expect(spec.description).toBe('Use as the first slide.');
97
+ });
98
+
99
+ test('schema validation still works on the returned component', () => {
100
+ const spec = defineTemplateComponent({
101
+ component: CoverComponent,
102
+ schema: CoverSchema,
103
+ description: 'Use as the first slide.',
104
+ });
105
+ expect(() => spec.schema.parse({ title: 'Hello' })).not.toThrow();
106
+ expect(() => spec.schema.parse({ title: 'Hello', extra: true })).toThrow();
107
+ });
108
+
109
+ test('two helper-returned components compose into Template.components without casts', () => {
110
+ // Compile-time test: if TypeScript accepts this without `as` casts,
111
+ // the variance erasure is working correctly.
112
+ const Cover = defineTemplateComponent({
113
+ component: CoverComponent,
114
+ schema: CoverSchema,
115
+ description: 'Use as the first slide. Sets title and stance.',
116
+ });
117
+ const TwoColumn = defineTemplateComponent({
118
+ component: TwoColumnComponent,
119
+ schema: TwoColumnSchema,
120
+ description: 'Use to compare two parallel ideas of equal weight.',
121
+ });
122
+
123
+ // No `as unknown as TemplateComponent` casts needed — this is the fix.
124
+ const brand: Template = {
125
+ name: 'test-brand',
126
+ canvas: CANVAS_16_9,
127
+ fonts: { display: ['Inter'], body: ['Inter'], mono: ['Courier New'] },
128
+ colors: { 'fg.base': '#0b0b0b' },
129
+ typography: {},
130
+ spacing: {},
131
+ components: { Cover, TwoColumn },
132
+ };
133
+
134
+ expect(Object.keys(brand.components)).toEqual(['Cover', 'TwoColumn']);
135
+ });
136
+ });
@@ -0,0 +1,37 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import type { z } from 'zod';
3
+ import type { FontStack } from './font-resolver.js';
4
+ import type { Canvas, Pt } from './geometry.js';
5
+ import type { HexColor } from './runtime.js';
6
+
7
+ export interface TemplateComponent<P = unknown> {
8
+ component: ComponentType<P>;
9
+ schema: z.ZodObject<z.ZodRawShape>;
10
+ masterRef?: string;
11
+ description: string;
12
+ }
13
+
14
+ export const defineTemplateComponent = <P>(spec: TemplateComponent<P>): TemplateComponent =>
15
+ spec as TemplateComponent;
16
+
17
+ export interface TypographyToken {
18
+ fontFamily: 'display' | 'body' | 'mono';
19
+ fontSize: Pt;
20
+ lineHeight: number;
21
+ letterSpacing?: number;
22
+ fontWeight?: number;
23
+ textTransform?: 'none' | 'uppercase' | 'lowercase';
24
+ }
25
+
26
+ export interface Template {
27
+ readonly name: string;
28
+ readonly canvas: Canvas;
29
+ readonly fonts: FontStack;
30
+ readonly colors: Readonly<Record<string, HexColor>>;
31
+ readonly typography: Readonly<Record<string, TypographyToken>>;
32
+ readonly spacing: Readonly<Record<string, Pt>>;
33
+ readonly components: Readonly<Record<string, TemplateComponent>>;
34
+ readonly preview?: () => ReactNode;
35
+ }
36
+
37
+ export const defineTemplate = (template: Template): Template => template;
@@ -0,0 +1,89 @@
1
+ import { Fragment, createElement, type ReactNode } from 'react';
2
+ import type { z } from 'zod';
3
+ import type { Template, TemplateComponent } from '../core/template.js';
4
+
5
+ export const deriveAutoPreview = (template: Template): ReactNode => {
6
+ const entries = Object.entries(template.components);
7
+ if (entries.length === 0) return null;
8
+ return createElement(
9
+ Fragment,
10
+ null,
11
+ ...entries.map(([name, tc]) => createElement(Fragment, { key: name }, renderSample(tc, name))),
12
+ );
13
+ };
14
+
15
+ const renderSample = (tc: TemplateComponent, name: string): ReactNode => {
16
+ const props = sampleProps(tc.schema, name);
17
+ return createElement(tc.component as (p: unknown) => ReactNode, props);
18
+ };
19
+ export const sampleProps = (schema: z.ZodTypeAny, contextName: string): Record<string, unknown> => {
20
+ const value = sample(schema, contextName);
21
+ return (value && typeof value === 'object' ? (value as Record<string, unknown>) : {}) ?? {};
22
+ };
23
+
24
+ const sample = (schema: z.ZodTypeAny, ctx: string): unknown => {
25
+ const def = (schema as unknown as { _def: { typeName: string } })._def;
26
+ switch (def.typeName) {
27
+ case 'ZodObject':
28
+ return sampleObject(schema as z.ZodObject<z.ZodRawShape>, ctx);
29
+ case 'ZodString':
30
+ return sampleString(schema, ctx);
31
+ case 'ZodNumber':
32
+ return 1;
33
+ case 'ZodBoolean':
34
+ return false;
35
+ case 'ZodEnum':
36
+ case 'ZodNativeEnum': {
37
+ const values = (def as unknown as { values: unknown }).values;
38
+ const arr = Array.isArray(values) ? values : Object.values(values as object);
39
+ return arr[0];
40
+ }
41
+ case 'ZodLiteral':
42
+ return (def as unknown as { value: unknown }).value;
43
+ case 'ZodUnion':
44
+ case 'ZodDiscriminatedUnion': {
45
+ const options = (def as unknown as { options: z.ZodTypeAny[] }).options;
46
+ return options.length > 0 && options[0] ? sample(options[0], ctx) : undefined;
47
+ }
48
+ case 'ZodArray': {
49
+ const item = (def as unknown as { type: z.ZodTypeAny }).type;
50
+ return [sample(item, ctx), sample(item, ctx)];
51
+ }
52
+ case 'ZodOptional':
53
+ case 'ZodNullable':
54
+ case 'ZodDefault': {
55
+ const inner = (def as unknown as { innerType: z.ZodTypeAny }).innerType;
56
+ return sample(inner, ctx);
57
+ }
58
+ case 'ZodEffects': {
59
+ const inner = (def as unknown as { schema: z.ZodTypeAny }).schema;
60
+ return sample(inner, ctx);
61
+ }
62
+ default:
63
+ return undefined;
64
+ }
65
+ };
66
+
67
+ const sampleObject = (schema: z.ZodObject<z.ZodRawShape>, ctx: string): Record<string, unknown> => {
68
+ const shape = schema.shape;
69
+ const out: Record<string, unknown> = {};
70
+ for (const [key, child] of Object.entries(shape)) {
71
+ if (isOptional(child)) continue;
72
+ out[key] = sample(child, `${ctx}.${key}`);
73
+ }
74
+ return out;
75
+ };
76
+
77
+ const isOptional = (schema: z.ZodTypeAny): boolean => {
78
+ const name = (schema as unknown as { _def: { typeName: string } })._def.typeName;
79
+ return name === 'ZodOptional' || name === 'ZodDefault' || name === 'ZodNullable';
80
+ };
81
+
82
+ const sampleString = (schema: z.ZodTypeAny, ctx: string): string => {
83
+ const checks =
84
+ (schema as unknown as { _def: { checks?: Array<{ kind: string; value?: number }> } })._def
85
+ .checks ?? [];
86
+ const min = checks.find((c) => c.kind === 'min')?.value ?? 0;
87
+ const sample = `Sample ${ctx.split('.').pop() ?? 'value'}`;
88
+ return sample.length >= min ? sample : sample.padEnd(min, '.');
89
+ };
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const require = createRequire(import.meta.url);
10
+
11
+ const tsxLoader = require.resolve('tsx/esm');
12
+ const entry = resolve(here, 'slides-dev.ts');
13
+
14
+ const child = spawn(
15
+ process.execPath,
16
+ ['--import', `file://${tsxLoader}`, entry, ...process.argv.slice(2)],
17
+ { stdio: 'inherit' },
18
+ );
19
+
20
+ child.on('exit', (code) => process.exit(code ?? 1));
21
+ child.on('error', (err) => {
22
+ process.stderr.write(`slides-dev shim failed: ${err.message}\n`);
23
+ process.exit(1);
24
+ });
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { realpathSync } from 'node:fs';
4
+ import { basename } from 'node:path';
5
+ import pc from 'picocolors';
6
+ import { startDevServer } from '../dev-server/start.js';
7
+ import { formatBanner, formatReady, formatStartupError } from '../dev-server/output.js';
8
+
9
+ const USAGE = `${pc.bold('Usage:')} slides-dev [options]
10
+
11
+ ${pc.bold('Options:')}
12
+ --port <n> Dev server port. Default: 5173.
13
+ --host <h> Dev server host. Default: localhost.
14
+ Pass --host with no value to expose on the network.
15
+ -h, --help Show this help.
16
+
17
+ Run from your template package directory.
18
+ `;
19
+
20
+ type ParsedFlags = {
21
+ port?: number;
22
+ host?: string;
23
+ exposed: boolean;
24
+ };
25
+
26
+ const parseFlags = (argv: readonly string[]): ParsedFlags => {
27
+ const out: ParsedFlags = { exposed: false };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const arg = argv[i];
30
+ if (arg === '--help' || arg === '-h') {
31
+ process.stdout.write(USAGE);
32
+ process.exit(0);
33
+ }
34
+ if (arg === '--port') {
35
+ const v = argv[++i];
36
+ if (!v) throw new Error('Missing value for --port');
37
+ out.port = Number.parseInt(v, 10);
38
+ continue;
39
+ }
40
+ if (arg === '--host') {
41
+ const v = argv[i + 1];
42
+ if (v && !v.startsWith('--')) {
43
+ out.host = v;
44
+ i++;
45
+ } else {
46
+ out.host = '0.0.0.0';
47
+ }
48
+ out.exposed = true;
49
+ continue;
50
+ }
51
+ throw new Error(`Unknown argument: ${arg}`);
52
+ }
53
+ return out;
54
+ };
55
+
56
+ const main = async (): Promise<void> => {
57
+ const flags = parseFlags(process.argv.slice(2));
58
+ const handle = await startDevServer({
59
+ cwd: process.cwd(),
60
+ ...(flags.port !== undefined ? { port: flags.port } : {}),
61
+ ...(flags.host !== undefined ? { host: flags.host } : {}),
62
+ });
63
+
64
+ const templateName = inferTemplateName(handle.templatePath);
65
+ process.stdout.write(formatBanner(templateName, handle.startedInMs));
66
+ process.stdout.write(
67
+ formatReady({
68
+ url: handle.url,
69
+ templatePath: handle.templatePath,
70
+ host: flags.exposed,
71
+ }),
72
+ );
73
+ process.stdout.write('\n');
74
+
75
+ const shutdown = async () => {
76
+ await handle.server.close();
77
+ process.exit(0);
78
+ };
79
+ process.on('SIGINT', shutdown);
80
+ process.on('SIGTERM', shutdown);
81
+ };
82
+
83
+ const findPackageDirName = (templatePath: string): string | undefined => {
84
+ const parts = templatePath.split('/').filter(Boolean);
85
+ for (let i = parts.length - 1; i >= 0; i--) {
86
+ if (parts[i] === 'src' && i > 0) return parts[i - 1];
87
+ }
88
+ return undefined;
89
+ };
90
+
91
+ const inferTemplateName = (templatePath: string): string =>
92
+ findPackageDirName(templatePath) ?? basename(templatePath, '.ts');
93
+
94
+ const argvPath = process.argv[1];
95
+ const isEntrypoint = argvPath !== undefined && import.meta.filename === realpathSync(argvPath);
96
+ if (isEntrypoint) {
97
+ main().catch((err: unknown) => {
98
+ process.stderr.write(formatStartupError(err));
99
+ process.exit(1);
100
+ });
101
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createElement, Fragment, type ReactElement } from 'react';
3
+ import { Slide, Box, Text } from '../core/components.js';
4
+ import { CANVAS_16_9 } from '../core/geometry.js';
5
+ import type { Template } from '../core/template.js';
6
+ import { composeDeck } from './compose-deck.js';
7
+
8
+ const STUB_TEMPLATE: Template = {
9
+ name: 'stub',
10
+ canvas: CANVAS_16_9,
11
+ fonts: { display: ['Inter'], body: ['Inter'], mono: ['Courier'] },
12
+ colors: {},
13
+ typography: {},
14
+ spacing: {},
15
+ components: {},
16
+ };
17
+
18
+ const TwoSlideDeck = (): ReactElement =>
19
+ createElement(
20
+ Fragment,
21
+ null,
22
+ createElement(
23
+ Slide,
24
+ { key: 'a' },
25
+ createElement(
26
+ Box,
27
+ { rect: { x: 0, y: 0, w: 200, h: 50 } },
28
+ createElement(Text, null, 'Hello'),
29
+ ),
30
+ ),
31
+ createElement(
32
+ Slide,
33
+ { key: 'b' },
34
+ createElement(
35
+ Box,
36
+ { rect: { x: 0, y: 0, w: 200, h: 50 } },
37
+ createElement(Text, null, 'World'),
38
+ ),
39
+ ),
40
+ );
41
+
42
+ describe('composeDeck', () => {
43
+ test('compiles a multi-slide JSX tree into a FakeDeck the viewer can render', async () => {
44
+ const result = await composeDeck({
45
+ tree: createElement(TwoSlideDeck),
46
+ template: STUB_TEMPLATE,
47
+ });
48
+
49
+ expect(result.deck.slideOrder).toHaveLength(2);
50
+
51
+ const shapeTexts = [...result.deck.shapes.values()]
52
+ .map((s) => s.text)
53
+ .filter((t) => t.length > 0);
54
+ expect(shapeTexts).toEqual(['Hello', 'World']);
55
+
56
+ expect(result.manifest.deckId).toBe('dev-deck');
57
+ expect(result.manifest.templateName).toBe('stub');
58
+ });
59
+
60
+ test('honours the pinned timestamp for deterministic manifests', async () => {
61
+ const result = await composeDeck({
62
+ tree: createElement(Slide),
63
+ template: STUB_TEMPLATE,
64
+ now: () => '2026-01-01T00:00:00.000Z',
65
+ });
66
+ expect(result.manifest.generatedAt).toBe('2026-01-01T00:00:00.000Z');
67
+ });
68
+ });
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react';
2
+ import { renderToOps } from '../core/reconciler.js';
3
+ import { FakeSlidesRuntime, type FakeDeck } from '../core/fake-runtime.js';
4
+ import type { GenerationManifest } from '../core/manifest.js';
5
+ import type { SlideOp } from '../core/runtime.js';
6
+ import type { Template } from '../core/template.js';
7
+
8
+ export type ComposeDeckInput = {
9
+ readonly tree: ReactNode;
10
+ readonly template: Template;
11
+ readonly title?: string;
12
+ readonly now?: () => string;
13
+ };
14
+
15
+ export type ComposedDeck = {
16
+ readonly deck: FakeDeck;
17
+ readonly ops: readonly SlideOp[];
18
+ readonly manifest: GenerationManifest;
19
+ };
20
+
21
+ const DECK_ID = 'dev-deck';
22
+
23
+ export const composeDeck = async (input: ComposeDeckInput): Promise<ComposedDeck> => {
24
+ const runtime = new FakeSlidesRuntime({ fixedDeckId: DECK_ID });
25
+ await runtime.createDeckFromMaster(input.template.name, input.title ?? 'Dev deck');
26
+
27
+ const reconciled = renderToOps({
28
+ tree: input.tree,
29
+ template: input.template,
30
+ deckId: DECK_ID,
31
+ ...(input.now ? { now: input.now } : {}),
32
+ });
33
+
34
+ await runtime.applyOps(DECK_ID, reconciled.ops);
35
+
36
+ const deck = runtime.getDeck(DECK_ID);
37
+ if (!deck) throw new Error('composeDeck: FakeSlidesRuntime lost track of the dev deck.');
38
+
39
+ return { deck, ops: reconciled.ops, manifest: reconciled.manifest };
40
+ };