@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.
- package/README.md +241 -0
- package/SKILL.md +119 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +386 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/components.d.ts +179 -0
- package/dist/core/components.d.ts.map +1 -0
- package/dist/core/components.js +40 -0
- package/dist/core/components.js.map +1 -0
- package/dist/core/fake-runtime.d.ts +138 -0
- package/dist/core/fake-runtime.d.ts.map +1 -0
- package/dist/core/fake-runtime.js +210 -0
- package/dist/core/fake-runtime.js.map +1 -0
- package/dist/core/font-resolver.d.ts +28 -0
- package/dist/core/font-resolver.d.ts.map +1 -0
- package/dist/core/font-resolver.js +30 -0
- package/dist/core/font-resolver.js.map +1 -0
- package/dist/core/geometry.d.ts +71 -0
- package/dist/core/geometry.d.ts.map +1 -0
- package/dist/core/geometry.js +44 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +20 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/manifest.d.ts +123 -0
- package/dist/core/manifest.d.ts.map +1 -0
- package/dist/core/manifest.js +43 -0
- package/dist/core/manifest.js.map +1 -0
- package/dist/core/op-translator-pptx.d.ts +150 -0
- package/dist/core/op-translator-pptx.d.ts.map +1 -0
- package/dist/core/op-translator-pptx.js +245 -0
- package/dist/core/op-translator-pptx.js.map +1 -0
- package/dist/core/pptx-runtime.d.ts +103 -0
- package/dist/core/pptx-runtime.d.ts.map +1 -0
- package/dist/core/pptx-runtime.js +405 -0
- package/dist/core/pptx-runtime.js.map +1 -0
- package/dist/core/reconciler.d.ts +113 -0
- package/dist/core/reconciler.d.ts.map +1 -0
- package/dist/core/reconciler.js +453 -0
- package/dist/core/reconciler.js.map +1 -0
- package/dist/core/runtime.d.ts +161 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +11 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/template.d.ts +32 -0
- package/dist/core/template.d.ts.map +1 -0
- package/dist/core/template.js +3 -0
- package/dist/core/template.js.map +1 -0
- package/dist/dev/auto-examples.d.ts +6 -0
- package/dist/dev/auto-examples.d.ts.map +1 -0
- package/dist/dev/auto-examples.js +79 -0
- package/dist/dev/auto-examples.js.map +1 -0
- package/dist/dev/bin/slides-dev.d.ts +3 -0
- package/dist/dev/bin/slides-dev.d.ts.map +1 -0
- package/dist/dev/bin/slides-dev.js +87 -0
- package/dist/dev/bin/slides-dev.js.map +1 -0
- package/dist/dev/bin/slides-dev.mjs +24 -0
- package/dist/dev/compose-deck.d.ts +18 -0
- package/dist/dev/compose-deck.d.ts.map +1 -0
- package/dist/dev/compose-deck.js +19 -0
- package/dist/dev/compose-deck.js.map +1 -0
- package/dist/dev/deck-viewer.d.ts +19 -0
- package/dist/dev/deck-viewer.d.ts.map +1 -0
- package/dist/dev/deck-viewer.js +237 -0
- package/dist/dev/deck-viewer.js.map +1 -0
- package/dist/dev/dev-server/client/entry.d.ts +2 -0
- package/dist/dev/dev-server/client/entry.d.ts.map +1 -0
- package/dist/dev/dev-server/client/entry.js +12 -0
- package/dist/dev/dev-server/client/entry.js.map +1 -0
- package/dist/dev/dev-server/output.d.ts +8 -0
- package/dist/dev/dev-server/output.d.ts.map +1 -0
- package/dist/dev/dev-server/output.js +32 -0
- package/dist/dev/dev-server/output.js.map +1 -0
- package/dist/dev/dev-server/server-only-stub.d.ts +7 -0
- package/dist/dev/dev-server/server-only-stub.d.ts.map +1 -0
- package/dist/dev/dev-server/server-only-stub.js +12 -0
- package/dist/dev/dev-server/server-only-stub.js.map +1 -0
- package/dist/dev/dev-server/start.d.ts +14 -0
- package/dist/dev/dev-server/start.d.ts.map +1 -0
- package/dist/dev/dev-server/start.js +135 -0
- package/dist/dev/dev-server/start.js.map +1 -0
- package/dist/dev/index.d.ts +5 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +5 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/dev/lib/cn.d.ts +3 -0
- package/dist/dev/lib/cn.d.ts.map +1 -0
- package/dist/dev/lib/cn.js +3 -0
- package/dist/dev/lib/cn.js.map +1 -0
- package/dist/dev/slide-canvas.d.ts +12 -0
- package/dist/dev/slide-canvas.d.ts.map +1 -0
- package/dist/dev/slide-canvas.js +123 -0
- package/dist/dev/slide-canvas.js.map +1 -0
- package/dist/dev/styles.css +37 -0
- package/dist/dev/ui/icon-button.d.ts +12 -0
- package/dist/dev/ui/icon-button.d.ts.map +1 -0
- package/dist/dev/ui/icon-button.js +6 -0
- package/dist/dev/ui/icon-button.js.map +1 -0
- package/dist/dev/ui/kbd.d.ts +6 -0
- package/dist/dev/ui/kbd.d.ts.map +1 -0
- package/dist/dev/ui/kbd.js +4 -0
- package/dist/dev/ui/kbd.js.map +1 -0
- package/dist/dev/ui/text-button.d.ts +10 -0
- package/dist/dev/ui/text-button.d.ts.map +1 -0
- package/dist/dev/ui/text-button.js +6 -0
- package/dist/dev/ui/text-button.js.map +1 -0
- package/dist/dev/url-state.d.ts +7 -0
- package/dist/dev/url-state.d.ts.map +1 -0
- package/dist/dev/url-state.js +13 -0
- package/dist/dev/url-state.js.map +1 -0
- package/dist/dev/use-keyboard-nav.d.ts +17 -0
- package/dist/dev/use-keyboard-nav.d.ts.map +1 -0
- package/dist/dev/use-keyboard-nav.js +53 -0
- package/dist/dev/use-keyboard-nav.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/errors.d.ts +57 -0
- package/dist/mcp/errors.d.ts.map +1 -0
- package/dist/mcp/errors.js +44 -0
- package/dist/mcp/errors.js.map +1 -0
- package/dist/mcp/index.d.ts +29 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +29 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/naming.d.ts +37 -0
- package/dist/mcp/naming.d.ts.map +1 -0
- package/dist/mcp/naming.js +43 -0
- package/dist/mcp/naming.js.map +1 -0
- package/dist/mcp/render.d.ts +45 -0
- package/dist/mcp/render.d.ts.map +1 -0
- package/dist/mcp/render.js +77 -0
- package/dist/mcp/render.js.map +1 -0
- package/dist/mcp/schema.d.ts +54 -0
- package/dist/mcp/schema.d.ts.map +1 -0
- package/dist/mcp/schema.js +55 -0
- package/dist/mcp/schema.js.map +1 -0
- package/dist/mcp/server.d.ts +63 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +196 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/scaffold/index.d.ts +39 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +84 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/scaffold/template-base/README.md +134 -0
- package/dist/scaffold/template-base/_gitignore +4 -0
- package/dist/scaffold/template-base/package.json +35 -0
- package/dist/scaffold/template-base/src/components/Cover.tsx +30 -0
- package/dist/scaffold/template-base/src/index.ts +27 -0
- package/dist/scaffold/template-base/src/preview.tsx +9 -0
- package/dist/scaffold/template-base/tsconfig.build.json +10 -0
- package/dist/scaffold/template-base/tsconfig.json +18 -0
- package/package.json +164 -0
- package/src/__tests__/fixtures/test-template/index.tsx +77 -0
- package/src/__tests__/pptx-mcp.test.ts +85 -0
- package/src/__tests__/pptx-smoke.test.ts +45 -0
- package/src/__tests__/preview.test.ts +28 -0
- package/src/cli.ts +426 -0
- package/src/core/__snapshots__/reconciler.test.ts.snap +320 -0
- package/src/core/components.test.ts +57 -0
- package/src/core/components.ts +196 -0
- package/src/core/fake-runtime.test.ts +174 -0
- package/src/core/fake-runtime.ts +302 -0
- package/src/core/font-resolver.ts +46 -0
- package/src/core/geometry.test.ts +58 -0
- package/src/core/geometry.ts +91 -0
- package/src/core/index.ts +69 -0
- package/src/core/manifest.test.ts +33 -0
- package/src/core/manifest.ts +150 -0
- package/src/core/op-translator-pptx.test.ts +204 -0
- package/src/core/op-translator-pptx.ts +365 -0
- package/src/core/pptx-runtime.test.ts +137 -0
- package/src/core/pptx-runtime.ts +504 -0
- package/src/core/reconciler.test.ts +644 -0
- package/src/core/reconciler.ts +603 -0
- package/src/core/runtime.ts +150 -0
- package/src/core/template.test.ts +136 -0
- package/src/core/template.ts +37 -0
- package/src/dev/auto-examples.ts +89 -0
- package/src/dev/bin/slides-dev.mjs +24 -0
- package/src/dev/bin/slides-dev.ts +101 -0
- package/src/dev/compose-deck.test.ts +68 -0
- package/src/dev/compose-deck.ts +40 -0
- package/src/dev/deck-viewer.tsx +677 -0
- package/src/dev/dev-server/client/entry.tsx +15 -0
- package/src/dev/dev-server/client/index.html +24 -0
- package/src/dev/dev-server/output.ts +37 -0
- package/src/dev/dev-server/server-only-stub.ts +12 -0
- package/src/dev/dev-server/start.ts +155 -0
- package/src/dev/index.ts +4 -0
- package/src/dev/lib/cn.ts +3 -0
- package/src/dev/slide-canvas.test.tsx +66 -0
- package/src/dev/slide-canvas.tsx +170 -0
- package/src/dev/styles.css +37 -0
- package/src/dev/ui/icon-button.tsx +31 -0
- package/src/dev/ui/kbd.tsx +20 -0
- package/src/dev/ui/text-button.tsx +31 -0
- package/src/dev/url-state.test.ts +22 -0
- package/src/dev/url-state.ts +17 -0
- package/src/dev/use-keyboard-nav.ts +64 -0
- package/src/index.ts +17 -0
- package/src/mcp/errors.test.ts +51 -0
- package/src/mcp/errors.ts +76 -0
- package/src/mcp/index.ts +45 -0
- package/src/mcp/naming.test.ts +39 -0
- package/src/mcp/naming.ts +49 -0
- package/src/mcp/render.ts +110 -0
- package/src/mcp/schema.test.ts +86 -0
- package/src/mcp/schema.ts +93 -0
- package/src/mcp/server.test.ts +309 -0
- package/src/mcp/server.ts +276 -0
- package/src/scaffold/index.ts +102 -0
- package/src/scaffold/template-base/README.md +134 -0
- package/src/scaffold/template-base/_gitignore +4 -0
- package/src/scaffold/template-base/package.json +35 -0
- package/src/scaffold/template-base/src/components/Cover.tsx +30 -0
- package/src/scaffold/template-base/src/index.ts +27 -0
- package/src/scaffold/template-base/src/preview.tsx +9 -0
- package/src/scaffold/template-base/tsconfig.build.json +10 -0
- package/src/scaffold/template-base/tsconfig.json +18 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { createElement, Fragment } from 'react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { Template } from './template.js';
|
|
5
|
+
import { Box, Color, Text, Image, Slide } from './components.js';
|
|
6
|
+
import { CANVAS_16_9 } from './geometry.js';
|
|
7
|
+
import { renderToOps, ReconcilerError } from './reconciler.js';
|
|
8
|
+
|
|
9
|
+
// A pinned brand for snapshot stability. Real brands live downstream.
|
|
10
|
+
const TestBrand: Template = {
|
|
11
|
+
name: 'test',
|
|
12
|
+
canvas: CANVAS_16_9,
|
|
13
|
+
fonts: {
|
|
14
|
+
display: ['Geist', 'Inter', 'Arial'],
|
|
15
|
+
body: ['Inter', 'Arial'],
|
|
16
|
+
mono: ['IBM Plex Mono', 'Courier New'],
|
|
17
|
+
},
|
|
18
|
+
colors: {
|
|
19
|
+
'fg.base': '#0b0b0b',
|
|
20
|
+
'fg.accent': '#ff5500',
|
|
21
|
+
'bg.surface': '#ffffff',
|
|
22
|
+
},
|
|
23
|
+
typography: {
|
|
24
|
+
'display-xl': { fontFamily: 'display', fontSize: 56, lineHeight: 1.1 },
|
|
25
|
+
'body-md': { fontFamily: 'body', fontSize: 18, lineHeight: 1.5 },
|
|
26
|
+
},
|
|
27
|
+
spacing: { sm: 8, md: 12, lg: 24 },
|
|
28
|
+
components: {
|
|
29
|
+
Cover: {
|
|
30
|
+
component: () => null,
|
|
31
|
+
schema: z.object({}).strict(),
|
|
32
|
+
description: 'Use as the first slide. Sets title and stance for the deck.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const FIXED_NOW = () => '2026-05-04T15:00:00.000Z';
|
|
38
|
+
|
|
39
|
+
describe('renderToOps — empty deck', () => {
|
|
40
|
+
test('a fragment with no slides emits no ops and an empty slot map', () => {
|
|
41
|
+
const result = renderToOps({
|
|
42
|
+
tree: createElement(Fragment, null),
|
|
43
|
+
template: TestBrand,
|
|
44
|
+
deckId: null,
|
|
45
|
+
now: FIXED_NOW,
|
|
46
|
+
});
|
|
47
|
+
expect(result.ops).toEqual([]);
|
|
48
|
+
expect(result.manifest.slots).toEqual({});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('renderToOps — single slide with one box', () => {
|
|
53
|
+
test('emits createSlide → createShape → insertText in order', () => {
|
|
54
|
+
const tree = createElement(
|
|
55
|
+
Slide,
|
|
56
|
+
null,
|
|
57
|
+
createElement(
|
|
58
|
+
Box,
|
|
59
|
+
{ rect: { x: 54, y: 54, w: 600, h: 100 } },
|
|
60
|
+
createElement(Text, null, 'Hello, world.'),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
64
|
+
expect(result.ops).toMatchSnapshot('ops');
|
|
65
|
+
expect(result.manifest).toMatchSnapshot('manifest');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('renderToOps — multiple text runs in one Box', () => {
|
|
70
|
+
test('concatenates text and emits per-run style spans for non-empty styles', () => {
|
|
71
|
+
const tree = createElement(
|
|
72
|
+
Slide,
|
|
73
|
+
null,
|
|
74
|
+
createElement(
|
|
75
|
+
Box,
|
|
76
|
+
{ rect: { x: 54, y: 54, w: 600, h: 100 } },
|
|
77
|
+
createElement(Text, { textStyle: { bold: true } }, 'Bold '),
|
|
78
|
+
createElement(Text, null, 'middle '),
|
|
79
|
+
createElement(Color, { color: '#ff5500' }, 'orange'),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
83
|
+
expect(result.ops).toMatchSnapshot('ops');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('renderToOps — slotId attaches the shape to the manifest', () => {
|
|
88
|
+
test('records SlotId → shapeId for each slot-bearing Box', () => {
|
|
89
|
+
const tree = createElement(
|
|
90
|
+
Slide,
|
|
91
|
+
null,
|
|
92
|
+
createElement(
|
|
93
|
+
Box,
|
|
94
|
+
{ rect: { x: 54, y: 54, w: 600, h: 60 }, slotId: 'cover:title' },
|
|
95
|
+
'Q2 review',
|
|
96
|
+
),
|
|
97
|
+
createElement(
|
|
98
|
+
Box,
|
|
99
|
+
{ rect: { x: 54, y: 130, w: 600, h: 40 }, slotId: 'cover:subtitle' },
|
|
100
|
+
'For internal review',
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
104
|
+
expect(result.manifest.slots).toMatchSnapshot('slot map');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('throws on duplicate slotIds', () => {
|
|
108
|
+
const tree = createElement(
|
|
109
|
+
Slide,
|
|
110
|
+
null,
|
|
111
|
+
createElement(Box, { rect: { x: 0, y: 0, w: 10, h: 10 }, slotId: 'cover:title' }, 'A'),
|
|
112
|
+
createElement(Box, { rect: { x: 20, y: 0, w: 10, h: 10 }, slotId: 'cover:title' }, 'B'),
|
|
113
|
+
);
|
|
114
|
+
expect(() => renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW })).toThrow(
|
|
115
|
+
/Duplicate slotId "cover:title"/,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('renderToOps — function components compose', () => {
|
|
121
|
+
test('a function component returning <Slide> resolves correctly', () => {
|
|
122
|
+
const Cover = ({ title }: { title: string }) =>
|
|
123
|
+
createElement(
|
|
124
|
+
Slide,
|
|
125
|
+
null,
|
|
126
|
+
createElement(
|
|
127
|
+
Box,
|
|
128
|
+
{ rect: { x: 54, y: 54, w: 600, h: 100 }, slotId: 'cover:title' },
|
|
129
|
+
createElement(Text, { textStyle: { bold: true } }, title),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
const result = renderToOps({
|
|
133
|
+
tree: createElement(Cover, { title: 'Hello' }),
|
|
134
|
+
template: TestBrand,
|
|
135
|
+
deckId: null,
|
|
136
|
+
now: FIXED_NOW,
|
|
137
|
+
});
|
|
138
|
+
expect(result.ops).toMatchSnapshot('ops');
|
|
139
|
+
expect(result.manifest.slots).toEqual({ 'cover:title': 'shape_2' });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('renderToOps — multi-slide deck', () => {
|
|
144
|
+
test('emits ops for each slide in document order with stable IDs', () => {
|
|
145
|
+
const tree = createElement(
|
|
146
|
+
Fragment,
|
|
147
|
+
null,
|
|
148
|
+
createElement(Slide, null, createElement(Box, { rect: { x: 0, y: 0, w: 100, h: 50 } }, 'A')),
|
|
149
|
+
createElement(Slide, null, createElement(Box, { rect: { x: 0, y: 0, w: 100, h: 50 } }, 'B')),
|
|
150
|
+
);
|
|
151
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
152
|
+
expect(result.ops).toMatchSnapshot('ops');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('renderToOps — Box-level styles', () => {
|
|
157
|
+
test('Box textStyle and paragraphStyle become full-range update ops', () => {
|
|
158
|
+
const tree = createElement(
|
|
159
|
+
Slide,
|
|
160
|
+
null,
|
|
161
|
+
createElement(
|
|
162
|
+
Box,
|
|
163
|
+
{
|
|
164
|
+
rect: { x: 54, y: 54, w: 600, h: 100 },
|
|
165
|
+
textStyle: { fontFamily: 'Geist', fontSize: 56, foregroundColor: '#0b0b0b' },
|
|
166
|
+
paragraphStyle: { alignment: 'START', lineSpacing: 1.1 },
|
|
167
|
+
},
|
|
168
|
+
'Title',
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
172
|
+
expect(result.ops).toMatchSnapshot('ops');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('renderToOps — error paths', () => {
|
|
177
|
+
test('rejects a non-Slide top-level element', () => {
|
|
178
|
+
const tree = createElement(Box, { rect: { x: 0, y: 0, w: 10, h: 10 } }, 'orphan');
|
|
179
|
+
expect(() => renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW })).toThrow(
|
|
180
|
+
/Top-level children must be <Slide>/,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('rejects a non-Box child of a Slide', () => {
|
|
185
|
+
const tree = createElement(Slide, null, createElement(Text, null, 'orphan text'));
|
|
186
|
+
expect(() => renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW })).toThrow(
|
|
187
|
+
/<Slide> children must be <Box>/,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('rejects a <Slide> nested inside a <Box>', () => {
|
|
192
|
+
const tree = createElement(
|
|
193
|
+
Slide,
|
|
194
|
+
null,
|
|
195
|
+
createElement(Box, { rect: { x: 0, y: 0, w: 10, h: 10 } }, createElement(Slide, null)),
|
|
196
|
+
);
|
|
197
|
+
expect(() => renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW })).toThrow(
|
|
198
|
+
/<Slide> cannot appear inside a <Box>/,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('error includes a Slide / Box path prefix when available', () => {
|
|
203
|
+
const tree = createElement(
|
|
204
|
+
Slide,
|
|
205
|
+
null,
|
|
206
|
+
createElement(Box, { rect: { x: 0, y: 0, w: 10, h: 10 } }, createElement('span', null)),
|
|
207
|
+
);
|
|
208
|
+
expect(() => renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW })).toThrow(
|
|
209
|
+
/Slide\[0\] > Box\[0\]/,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('ReconcilerError is the thrown class', () => {
|
|
214
|
+
try {
|
|
215
|
+
renderToOps({
|
|
216
|
+
tree: createElement('div', null),
|
|
217
|
+
template: TestBrand,
|
|
218
|
+
deckId: null,
|
|
219
|
+
now: FIXED_NOW,
|
|
220
|
+
});
|
|
221
|
+
throw new Error('expected throw');
|
|
222
|
+
} catch (e) {
|
|
223
|
+
expect(e).toBeInstanceOf(ReconcilerError);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('renderToOps — manifest fields', () => {
|
|
229
|
+
test('records brand name, deckId, and artifacts as provided', () => {
|
|
230
|
+
const result = renderToOps({
|
|
231
|
+
tree: createElement(Slide, null),
|
|
232
|
+
template: TestBrand,
|
|
233
|
+
deckId: 'existing-deck-123',
|
|
234
|
+
now: FIXED_NOW,
|
|
235
|
+
artifacts: [
|
|
236
|
+
{
|
|
237
|
+
type: 'texture',
|
|
238
|
+
identifier: 'dots-grid-base-medium-dark',
|
|
239
|
+
resolvedUrl: 'https://cdn.example.com/dots-grid.png',
|
|
240
|
+
resolvedAt: '2026-05-04T15:00:00.000Z',
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
expect(result.manifest.templateName).toBe('test');
|
|
245
|
+
expect(result.manifest.deckId).toBe('existing-deck-123');
|
|
246
|
+
expect(result.manifest.generatedAt).toBe('2026-05-04T15:00:00.000Z');
|
|
247
|
+
expect(result.manifest.artifacts).toHaveLength(1);
|
|
248
|
+
expect(result.manifest.artifacts[0]?.identifier).toBe('dots-grid-base-medium-dark');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// <Box fill> tests
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
describe('renderToOps — Box fill', () => {
|
|
257
|
+
test('an empty Box with a solid fill emits createShape → updateShapeProperties (no text ops)', () => {
|
|
258
|
+
const tree = createElement(
|
|
259
|
+
Slide,
|
|
260
|
+
null,
|
|
261
|
+
createElement(Box, {
|
|
262
|
+
rect: { x: 0, y: 0, w: 960, h: 540 },
|
|
263
|
+
fill: { kind: 'solid', color: '#ff5500' },
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
267
|
+
expect(result.ops).toMatchSnapshot('ops');
|
|
268
|
+
// Order: createSlide → createShape → updateShapeProperties.
|
|
269
|
+
expect(result.ops.map((op) => op.type)).toEqual([
|
|
270
|
+
'createSlide',
|
|
271
|
+
'createShape',
|
|
272
|
+
'updateShapeProperties',
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('a Box with both fill and text emits fill *before* insertText', () => {
|
|
277
|
+
const tree = createElement(
|
|
278
|
+
Slide,
|
|
279
|
+
null,
|
|
280
|
+
createElement(
|
|
281
|
+
Box,
|
|
282
|
+
{
|
|
283
|
+
rect: { x: 54, y: 54, w: 600, h: 100 },
|
|
284
|
+
fill: { kind: 'solid', color: '#0b0b0b' },
|
|
285
|
+
},
|
|
286
|
+
'Hello',
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
290
|
+
expect(result.ops).toMatchSnapshot('ops');
|
|
291
|
+
// The fill must land between createShape and insertText. This is what
|
|
292
|
+
// makes empty-fill and filled-text cases share an op-emission order.
|
|
293
|
+
const types = result.ops.map((op) => op.type);
|
|
294
|
+
const createShapeIdx = types.indexOf('createShape');
|
|
295
|
+
const updatePropsIdx = types.indexOf('updateShapeProperties');
|
|
296
|
+
const insertTextIdx = types.indexOf('insertText');
|
|
297
|
+
expect(createShapeIdx).toBeLessThan(updatePropsIdx);
|
|
298
|
+
expect(updatePropsIdx).toBeLessThan(insertTextIdx);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('updateShapeProperties carries the resolved fillColor', () => {
|
|
302
|
+
const tree = createElement(
|
|
303
|
+
Slide,
|
|
304
|
+
null,
|
|
305
|
+
createElement(Box, {
|
|
306
|
+
rect: { x: 0, y: 0, w: 100, h: 100 },
|
|
307
|
+
fill: { kind: 'solid', color: '#ff5500' },
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
311
|
+
const fillOp = result.ops.find((op) => op.type === 'updateShapeProperties');
|
|
312
|
+
expect(fillOp).toBeDefined();
|
|
313
|
+
if (fillOp?.type === 'updateShapeProperties') {
|
|
314
|
+
expect(fillOp.properties.fillColor).toBe('#ff5500');
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// <Image> tests
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
const HERO_ARTIFACT = {
|
|
324
|
+
type: 'image',
|
|
325
|
+
identifier: 'hero-photo',
|
|
326
|
+
resolvedUrl: 'https://cdn.example.com/hero.png',
|
|
327
|
+
resolvedAt: '2026-05-04T15:00:00.000Z',
|
|
328
|
+
} as const;
|
|
329
|
+
|
|
330
|
+
const TEXTURE_ARTIFACT = {
|
|
331
|
+
type: 'texture',
|
|
332
|
+
identifier: 'dots-grid-base-medium-dark',
|
|
333
|
+
resolvedUrl: 'https://cdn.example.com/dots-grid.png',
|
|
334
|
+
resolvedAt: '2026-05-04T15:00:00.000Z',
|
|
335
|
+
} as const;
|
|
336
|
+
|
|
337
|
+
describe('renderToOps — Image primitive', () => {
|
|
338
|
+
test('emits createSlide → createImage with the resolved URL and rect', () => {
|
|
339
|
+
const tree = createElement(
|
|
340
|
+
Slide,
|
|
341
|
+
null,
|
|
342
|
+
createElement(Image, {
|
|
343
|
+
rect: { x: 0, y: 0, w: 960, h: 540 },
|
|
344
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
348
|
+
expect(result.ops).toMatchSnapshot('ops');
|
|
349
|
+
expect(result.ops.map((op) => op.type)).toEqual(['createSlide', 'createImage']);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('records the artifact in manifest.artifacts', () => {
|
|
353
|
+
const tree = createElement(
|
|
354
|
+
Slide,
|
|
355
|
+
null,
|
|
356
|
+
createElement(Image, {
|
|
357
|
+
rect: { x: 0, y: 0, w: 960, h: 540 },
|
|
358
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
362
|
+
expect(result.manifest.artifacts).toEqual([HERO_ARTIFACT]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('two <Image>s with the same artifact identifier dedup to one manifest entry', () => {
|
|
366
|
+
const tree = createElement(
|
|
367
|
+
Fragment,
|
|
368
|
+
null,
|
|
369
|
+
createElement(
|
|
370
|
+
Slide,
|
|
371
|
+
null,
|
|
372
|
+
createElement(Image, {
|
|
373
|
+
rect: { x: 0, y: 0, w: 100, h: 100 },
|
|
374
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
375
|
+
}),
|
|
376
|
+
),
|
|
377
|
+
createElement(
|
|
378
|
+
Slide,
|
|
379
|
+
null,
|
|
380
|
+
createElement(Image, {
|
|
381
|
+
rect: { x: 0, y: 0, w: 100, h: 100 },
|
|
382
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
383
|
+
}),
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
387
|
+
expect(result.manifest.artifacts).toHaveLength(1);
|
|
388
|
+
expect(result.manifest.artifacts[0]?.identifier).toBe('hero-photo');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('multiple <Image>s with different identifiers each appear once in artifacts', () => {
|
|
392
|
+
const tree = createElement(
|
|
393
|
+
Slide,
|
|
394
|
+
null,
|
|
395
|
+
createElement(Image, {
|
|
396
|
+
rect: { x: 0, y: 0, w: 100, h: 100 },
|
|
397
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
398
|
+
}),
|
|
399
|
+
createElement(Image, {
|
|
400
|
+
rect: { x: 100, y: 0, w: 100, h: 100 },
|
|
401
|
+
image: { url: TEXTURE_ARTIFACT.resolvedUrl, artifact: TEXTURE_ARTIFACT },
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
405
|
+
expect(result.manifest.artifacts).toHaveLength(2);
|
|
406
|
+
expect(result.manifest.artifacts.map((a) => a.identifier).sort()).toEqual([
|
|
407
|
+
'dots-grid-base-medium-dark',
|
|
408
|
+
'hero-photo',
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('input.artifacts and discovered artifacts merge by identifier (image-walk wins on conflict)', () => {
|
|
413
|
+
// The caller-supplied artifact has the same identifier as the discovered
|
|
414
|
+
// one but a different resolvedUrl. The reconciler-discovered artifact
|
|
415
|
+
// should overwrite (last-wins per the documented merge rule).
|
|
416
|
+
const stale = {
|
|
417
|
+
...HERO_ARTIFACT,
|
|
418
|
+
resolvedUrl: 'https://stale.example.com/hero.png',
|
|
419
|
+
};
|
|
420
|
+
const tree = createElement(
|
|
421
|
+
Slide,
|
|
422
|
+
null,
|
|
423
|
+
createElement(Image, {
|
|
424
|
+
rect: { x: 0, y: 0, w: 100, h: 100 },
|
|
425
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
const result = renderToOps({
|
|
429
|
+
tree,
|
|
430
|
+
template: TestBrand,
|
|
431
|
+
deckId: null,
|
|
432
|
+
now: FIXED_NOW,
|
|
433
|
+
artifacts: [stale],
|
|
434
|
+
});
|
|
435
|
+
expect(result.manifest.artifacts).toHaveLength(1);
|
|
436
|
+
expect(result.manifest.artifacts[0]?.resolvedUrl).toBe(HERO_ARTIFACT.resolvedUrl);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('slotId on an <Image> registers the imageId in manifest.slots', () => {
|
|
440
|
+
const tree = createElement(
|
|
441
|
+
Slide,
|
|
442
|
+
null,
|
|
443
|
+
createElement(Image, {
|
|
444
|
+
rect: { x: 0, y: 0, w: 960, h: 540 },
|
|
445
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
446
|
+
slotId: 'cover:hero',
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
450
|
+
// Slot map points at the image's object id; runtime adapter stamps
|
|
451
|
+
// alt-text from this map via slotRegistryToAltTextRequests.
|
|
452
|
+
expect(result.manifest.slots).toEqual({ 'cover:hero': 'image_2' });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('user altText flows through to the createImage op', () => {
|
|
456
|
+
const tree = createElement(
|
|
457
|
+
Slide,
|
|
458
|
+
null,
|
|
459
|
+
createElement(Image, {
|
|
460
|
+
rect: { x: 0, y: 0, w: 100, h: 100 },
|
|
461
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
462
|
+
altText: 'Hero photo of the team',
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
466
|
+
const createImageOp = result.ops.find((op) => op.type === 'createImage');
|
|
467
|
+
expect(createImageOp).toBeDefined();
|
|
468
|
+
if (createImageOp?.type === 'createImage') {
|
|
469
|
+
expect(createImageOp.altText).toBe('Hero photo of the team');
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('throws on duplicate slotIds across Box and Image', () => {
|
|
474
|
+
// Slot uniqueness is global across primitive kinds, not per-kind.
|
|
475
|
+
const tree = createElement(
|
|
476
|
+
Slide,
|
|
477
|
+
null,
|
|
478
|
+
createElement(Box, { rect: { x: 0, y: 0, w: 10, h: 10 }, slotId: 'cover:hero' }, 'a'),
|
|
479
|
+
createElement(Image, {
|
|
480
|
+
rect: { x: 20, y: 0, w: 10, h: 10 },
|
|
481
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
482
|
+
slotId: 'cover:hero',
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
expect(() => renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW })).toThrow(
|
|
486
|
+
/Duplicate slotId "cover:hero"/,
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('<Image> nested inside a <Box> is rejected with a path-prefixed error', () => {
|
|
491
|
+
const tree = createElement(
|
|
492
|
+
Slide,
|
|
493
|
+
null,
|
|
494
|
+
createElement(
|
|
495
|
+
Box,
|
|
496
|
+
{ rect: { x: 0, y: 0, w: 10, h: 10 } },
|
|
497
|
+
createElement(Image, {
|
|
498
|
+
rect: { x: 0, y: 0, w: 10, h: 10 },
|
|
499
|
+
image: { url: HERO_ARTIFACT.resolvedUrl, artifact: HERO_ARTIFACT },
|
|
500
|
+
}),
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
expect(() => renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW })).toThrow(
|
|
504
|
+
/<Image> cannot appear inside a <Box>/,
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Role-aware fontFamily resolution
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
describe('renderToOps — role-aware fontFamily resolution', () => {
|
|
514
|
+
test('Box textStyle.fontFamily="display" resolves to brand.fonts.display[0]', () => {
|
|
515
|
+
const tree = createElement(
|
|
516
|
+
Slide,
|
|
517
|
+
null,
|
|
518
|
+
createElement(
|
|
519
|
+
Box,
|
|
520
|
+
{
|
|
521
|
+
rect: { x: 0, y: 0, w: 600, h: 100 },
|
|
522
|
+
textStyle: { fontFamily: 'display', fontSize: 56 },
|
|
523
|
+
},
|
|
524
|
+
'Title',
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
528
|
+
const styleOp = result.ops.find((op) => op.type === 'updateTextStyle');
|
|
529
|
+
expect(styleOp).toBeDefined();
|
|
530
|
+
if (styleOp?.type === 'updateTextStyle') {
|
|
531
|
+
// brand.fonts.display = ['Geist', 'Inter', 'Arial'] — first entry wins.
|
|
532
|
+
expect(styleOp.style.fontFamily).toBe('Geist');
|
|
533
|
+
// Other style fields preserved.
|
|
534
|
+
expect(styleOp.style.fontSize).toBe(56);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('Text textStyle.fontFamily="body" resolves to brand.fonts.body[0]', () => {
|
|
539
|
+
const tree = createElement(
|
|
540
|
+
Slide,
|
|
541
|
+
null,
|
|
542
|
+
createElement(
|
|
543
|
+
Box,
|
|
544
|
+
{ rect: { x: 0, y: 0, w: 600, h: 100 } },
|
|
545
|
+
createElement(Text, { textStyle: { fontFamily: 'body' } }, 'Body copy'),
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
549
|
+
const styleOp = result.ops.find((op) => op.type === 'updateTextStyle');
|
|
550
|
+
expect(styleOp).toBeDefined();
|
|
551
|
+
if (styleOp?.type === 'updateTextStyle') {
|
|
552
|
+
// brand.fonts.body = ['Inter', 'Arial'] — first preference (multi-stack).
|
|
553
|
+
expect(styleOp.style.fontFamily).toBe('Inter');
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('fontFamily="mono" resolves to brand.fonts.mono[0]', () => {
|
|
558
|
+
const tree = createElement(
|
|
559
|
+
Slide,
|
|
560
|
+
null,
|
|
561
|
+
createElement(
|
|
562
|
+
Box,
|
|
563
|
+
{ rect: { x: 0, y: 0, w: 600, h: 100 } },
|
|
564
|
+
createElement(Text, { textStyle: { fontFamily: 'mono' } }, 'console.log()'),
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
568
|
+
const styleOp = result.ops.find((op) => op.type === 'updateTextStyle');
|
|
569
|
+
if (styleOp?.type === 'updateTextStyle') {
|
|
570
|
+
// brand.fonts.mono = ['IBM Plex Mono', 'Courier New'].
|
|
571
|
+
expect(styleOp.style.fontFamily).toBe('IBM Plex Mono');
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('literal family names pass through unchanged (back-compat)', () => {
|
|
576
|
+
const tree = createElement(
|
|
577
|
+
Slide,
|
|
578
|
+
null,
|
|
579
|
+
createElement(
|
|
580
|
+
Box,
|
|
581
|
+
{
|
|
582
|
+
rect: { x: 0, y: 0, w: 600, h: 100 },
|
|
583
|
+
textStyle: { fontFamily: 'Geist' },
|
|
584
|
+
},
|
|
585
|
+
'Title',
|
|
586
|
+
),
|
|
587
|
+
);
|
|
588
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
589
|
+
const styleOp = result.ops.find((op) => op.type === 'updateTextStyle');
|
|
590
|
+
if (styleOp?.type === 'updateTextStyle') {
|
|
591
|
+
// 'Geist' is a literal family name; reconciler should not touch it,
|
|
592
|
+
// even though it happens to coincide with brand.fonts.display[0].
|
|
593
|
+
expect(styleOp.style.fontFamily).toBe('Geist');
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('mixed runs: per-run roles each resolve independently', () => {
|
|
598
|
+
// A Box with two Text runs, one display, one body — verify each op
|
|
599
|
+
// carries the correctly-resolved family.
|
|
600
|
+
const tree = createElement(
|
|
601
|
+
Slide,
|
|
602
|
+
null,
|
|
603
|
+
createElement(
|
|
604
|
+
Box,
|
|
605
|
+
{ rect: { x: 0, y: 0, w: 600, h: 100 } },
|
|
606
|
+
createElement(Text, { textStyle: { fontFamily: 'display' } }, 'Big '),
|
|
607
|
+
createElement(Text, { textStyle: { fontFamily: 'body' } }, 'small'),
|
|
608
|
+
),
|
|
609
|
+
);
|
|
610
|
+
const result = renderToOps({ tree, template: TestBrand, deckId: null, now: FIXED_NOW });
|
|
611
|
+
const styleOps = result.ops.filter((op) => op.type === 'updateTextStyle');
|
|
612
|
+
expect(styleOps).toHaveLength(2);
|
|
613
|
+
if (styleOps[0]?.type === 'updateTextStyle' && styleOps[1]?.type === 'updateTextStyle') {
|
|
614
|
+
expect(styleOps[0].style.fontFamily).toBe('Geist'); // display[0]
|
|
615
|
+
expect(styleOps[1].style.fontFamily).toBe('Inter'); // body[0]
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('throws a clear error when the brand defines no font for the requested role', () => {
|
|
620
|
+
const EmptyDisplayBrand: Template = {
|
|
621
|
+
...TestBrand,
|
|
622
|
+
fonts: {
|
|
623
|
+
display: [], // intentionally empty
|
|
624
|
+
body: ['Inter'],
|
|
625
|
+
mono: ['IBM Plex Mono'],
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
const tree = createElement(
|
|
629
|
+
Slide,
|
|
630
|
+
null,
|
|
631
|
+
createElement(
|
|
632
|
+
Box,
|
|
633
|
+
{
|
|
634
|
+
rect: { x: 0, y: 0, w: 600, h: 100 },
|
|
635
|
+
textStyle: { fontFamily: 'display' },
|
|
636
|
+
},
|
|
637
|
+
'Title',
|
|
638
|
+
),
|
|
639
|
+
);
|
|
640
|
+
expect(() =>
|
|
641
|
+
renderToOps({ tree, template: EmptyDisplayBrand, deckId: null, now: FIXED_NOW }),
|
|
642
|
+
).toThrow(/defines no display font/);
|
|
643
|
+
});
|
|
644
|
+
});
|