capture-flow 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +238 -2
- package/bin/capture-flow.mjs +12 -0
- package/package.json +55 -4
- package/src/build.ts +186 -0
- package/src/capture.ts +77 -0
- package/src/cli.ts +139 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +98 -0
- package/src/render.ts +75 -0
- package/src/verify.ts +90 -0
- package/vendor/wajha/LICENSE +21 -0
- package/vendor/wajha/NOTICE +21 -0
- package/vendor/wajha/engine/background.ts +55 -0
- package/vendor/wajha/engine/bidi.ts +589 -0
- package/vendor/wajha/engine/cache.ts +96 -0
- package/vendor/wajha/engine/color.ts +125 -0
- package/vendor/wajha/engine/devices/android.ts +53 -0
- package/vendor/wajha/engine/devices/browser.ts +170 -0
- package/vendor/wajha/engine/devices/finish.ts +154 -0
- package/vendor/wajha/engine/devices/geometry.ts +326 -0
- package/vendor/wajha/engine/devices/index.ts +143 -0
- package/vendor/wajha/engine/devices/ipad.ts +21 -0
- package/vendor/wajha/engine/devices/iphone.ts +55 -0
- package/vendor/wajha/engine/devices/macbook.ts +187 -0
- package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
- package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
- package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
- package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
- package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
- package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
- package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
- package/vendor/wajha/engine/devices/mock/art.ts +557 -0
- package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
- package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
- package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
- package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
- package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
- package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
- package/vendor/wajha/engine/devices/paths.ts +113 -0
- package/vendor/wajha/engine/devices/screen.ts +75 -0
- package/vendor/wajha/engine/devices/shadow.ts +124 -0
- package/vendor/wajha/engine/devices/slab.ts +200 -0
- package/vendor/wajha/engine/devices/vision.ts +43 -0
- package/vendor/wajha/engine/devices/watch.ts +14 -0
- package/vendor/wajha/engine/geometry.ts +152 -0
- package/vendor/wajha/engine/images.ts +96 -0
- package/vendor/wajha/engine/index.ts +20 -0
- package/vendor/wajha/engine/linebreak.ts +226 -0
- package/vendor/wajha/engine/markup.ts +84 -0
- package/vendor/wajha/engine/mock.ts +158 -0
- package/vendor/wajha/engine/paint.ts +474 -0
- package/vendor/wajha/engine/patterns.ts +263 -0
- package/vendor/wajha/engine/render.ts +364 -0
- package/vendor/wajha/engine/shapes.ts +260 -0
- package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
- package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
- package/vendor/wajha/engine/text.ts +694 -0
- package/vendor/wajha/engine/types.ts +190 -0
- package/vendor/wajha/export/paths.ts +197 -0
- package/vendor/wajha/export/png.ts +223 -0
- package/vendor/wajha/export/types.ts +60 -0
- package/vendor/wajha/model/adapt.ts +889 -0
- package/vendor/wajha/model/catalog.ts +369 -0
- package/vendor/wajha/model/clone.ts +58 -0
- package/vendor/wajha/model/colorMath.ts +128 -0
- package/vendor/wajha/model/defaults.ts +386 -0
- package/vendor/wajha/model/geometry.ts +95 -0
- package/vendor/wajha/model/ids.ts +13 -0
- package/vendor/wajha/model/instantiate.ts +280 -0
- package/vendor/wajha/model/mirror.ts +43 -0
- package/vendor/wajha/model/resolve.ts +87 -0
- package/vendor/wajha/model/rules.ts +249 -0
- package/vendor/wajha/model/stub.ts +4 -0
- package/vendor/wajha/model/types.ts +456 -0
- package/vendor/wajha/model/typography.ts +28 -0
- package/vendor/wajha/model/validate.ts +917 -0
- package/vendor/wajha/templates/builtin/aurora.ts +163 -0
- package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
- package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
- package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
- package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
- package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
- package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
- package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
- package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
- package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
- package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
- package/vendor/wajha/templates/builtin/noor.ts +107 -0
- package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
- package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
- package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
- package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
- package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
- package/vendor/wajha/templates/builtin/sage.ts +94 -0
- package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
- package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
- package/vendor/wajha/templates/index.ts +55 -0
- package/vendor/wajha/templates/kit.ts +761 -0
- package/vendor/wajha/ui/sortShots.ts +239 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builders for authoring the built-in templates as compact, readable code.
|
|
3
|
+
* Every builder returns plain model objects (see model/types.ts) that pass
|
|
4
|
+
* validateTemplate unchanged; the template modules under ./builtin/ give
|
|
5
|
+
* each design its own palette, type system and compositions on top.
|
|
6
|
+
*
|
|
7
|
+
* Coordinates follow the model: x / y are fractions of the canvas, lengths
|
|
8
|
+
* are fractions of u = min(width, height). Layouts are authored per aspect
|
|
9
|
+
* class on the reference canvases (tall = iPhone 1320 x 2868, tablet = iPad
|
|
10
|
+
* 2064 x 2752, wide = Mac 2880 x 1800); `canvas(cls)` gives their size in u.
|
|
11
|
+
*/
|
|
12
|
+
import { deviceGeometry, mockSeed } from '../engine';
|
|
13
|
+
import { mockArchetype, type MockArchetypeId } from '../engine/mock';
|
|
14
|
+
import { FAMILIES } from '../model/catalog';
|
|
15
|
+
import { deepClone } from '../model/clone';
|
|
16
|
+
import type {
|
|
17
|
+
AspectClass,
|
|
18
|
+
Background,
|
|
19
|
+
Color,
|
|
20
|
+
DeviceElement,
|
|
21
|
+
DeviceFrameId,
|
|
22
|
+
Fill,
|
|
23
|
+
FrameFinish,
|
|
24
|
+
GradientStop,
|
|
25
|
+
Pattern,
|
|
26
|
+
PatternKind,
|
|
27
|
+
Shadow,
|
|
28
|
+
ShapeElement,
|
|
29
|
+
ShapeKind,
|
|
30
|
+
SlideElement,
|
|
31
|
+
SlideLayout,
|
|
32
|
+
Template,
|
|
33
|
+
TemplateCategory,
|
|
34
|
+
TemplateSlide,
|
|
35
|
+
TextElement,
|
|
36
|
+
TextStyle,
|
|
37
|
+
Theme,
|
|
38
|
+
} from '../model/types';
|
|
39
|
+
|
|
40
|
+
/** Every bundled font, by short name (see BUNDLED_FONTS). */
|
|
41
|
+
export const F = {
|
|
42
|
+
inter: 'Inter Variable',
|
|
43
|
+
jakarta: 'Plus Jakarta Sans Variable',
|
|
44
|
+
manrope: 'Manrope Variable',
|
|
45
|
+
dmSans: 'DM Sans Variable',
|
|
46
|
+
outfit: 'Outfit Variable',
|
|
47
|
+
sora: 'Sora Variable',
|
|
48
|
+
spaceGrotesk: 'Space Grotesk Variable',
|
|
49
|
+
bricolage: 'Bricolage Grotesque Variable',
|
|
50
|
+
fraunces: 'Fraunces Variable',
|
|
51
|
+
playfair: 'Playfair Display Variable',
|
|
52
|
+
rubik: 'Rubik Variable',
|
|
53
|
+
cairo: 'Cairo Variable',
|
|
54
|
+
readex: 'Readex Pro Variable',
|
|
55
|
+
notoArabic: 'Noto Sans Arabic Variable',
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
export type Cls = AspectClass;
|
|
59
|
+
|
|
60
|
+
/** Rounds layout numbers so the JSON stays tidy (and diffs stay readable). */
|
|
61
|
+
export const r5 = (v: number): number => Math.round(v * 1e5) / 1e5;
|
|
62
|
+
|
|
63
|
+
/* ------------------------------------------------------------------ */
|
|
64
|
+
/* Canvases and devices */
|
|
65
|
+
/* ------------------------------------------------------------------ */
|
|
66
|
+
|
|
67
|
+
const REFERENCE = { tall: FAMILIES.iphone, tablet: FAMILIES.ipad, wide: FAMILIES.mac } as const;
|
|
68
|
+
|
|
69
|
+
export interface CanvasSize {
|
|
70
|
+
/** Width and height in u. */
|
|
71
|
+
wu: number;
|
|
72
|
+
hu: number;
|
|
73
|
+
/** Default screen aspect (height / width) of devices without a screenshot. */
|
|
74
|
+
aspect: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Size in u of the reference canvas a class layout is authored on. */
|
|
78
|
+
export function canvas(cls: Cls): CanvasSize {
|
|
79
|
+
const fam = REFERENCE[cls];
|
|
80
|
+
const u = Math.min(fam.design.width, fam.design.height);
|
|
81
|
+
return { wu: fam.design.width / u, hu: fam.design.height / u, aspect: fam.screenAspect };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Outer height (u) of a `frame` device `width` u wide showing the class's default screen. */
|
|
85
|
+
export function deviceHeight(cls: Cls, frame: DeviceFrameId, width: number): number {
|
|
86
|
+
return deviceGeometry(frame, width * 1000, canvas(cls).aspect).height / 1000;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
/* Paint */
|
|
91
|
+
/* ------------------------------------------------------------------ */
|
|
92
|
+
|
|
93
|
+
type StopInput = Color | [Color, number];
|
|
94
|
+
|
|
95
|
+
function stops(colors: StopInput[]): GradientStop[] {
|
|
96
|
+
const n = colors.length;
|
|
97
|
+
return colors.map((c, i) => (Array.isArray(c) ? { offset: c[1], color: c[0] } : { offset: n > 1 ? r5(i / (n - 1)) : 0, color: c }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const solid = (color: Color): Fill => ({ type: 'solid', color });
|
|
101
|
+
|
|
102
|
+
/** CSS-style linear gradient; plain colours are spaced evenly, [colour, offset] pins a stop. */
|
|
103
|
+
export const linear = (angle: number, ...colors: StopInput[]): Fill => ({ type: 'linear', angle, stops: stops(colors) });
|
|
104
|
+
|
|
105
|
+
/** Radial gradient: centre in canvas fractions, radius a fraction of the longer side. */
|
|
106
|
+
export const radial = (cx: number, cy: number, radius: number, ...colors: StopInput[]): Fill => ({ type: 'radial', cx, cy, radius, stops: stops(colors) });
|
|
107
|
+
|
|
108
|
+
/** Mesh ("aurora") fill: soft colour blobs [x, y, colour, radius] over a base colour. */
|
|
109
|
+
export const mesh = (base: Color, ...points: [number, number, Color, number][]): Fill => ({
|
|
110
|
+
type: 'mesh',
|
|
111
|
+
base,
|
|
112
|
+
points: points.map(([x, y, color, radius]) => ({ x, y, color, radius })),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const pattern = (kind: PatternKind, color: Color, opacity: number, scale: number): Pattern => ({ kind, color, opacity, scale });
|
|
116
|
+
|
|
117
|
+
export const background = (fill: Fill, p: Pattern | null = null): Background => ({ fill, pattern: p });
|
|
118
|
+
|
|
119
|
+
export const shadow = (color: Color, blur: number, y = 0, x = 0): Shadow => ({ color, blur, x, y });
|
|
120
|
+
|
|
121
|
+
/* ------------------------------------------------------------------ */
|
|
122
|
+
/* Text */
|
|
123
|
+
/* ------------------------------------------------------------------ */
|
|
124
|
+
|
|
125
|
+
/** Element and style fields of a text element in one flat object. */
|
|
126
|
+
export interface TextOptions extends Partial<TextStyle> {
|
|
127
|
+
id?: string;
|
|
128
|
+
key?: string;
|
|
129
|
+
x?: number;
|
|
130
|
+
y?: number;
|
|
131
|
+
width?: number;
|
|
132
|
+
valign?: TextElement['valign'];
|
|
133
|
+
rotation?: number;
|
|
134
|
+
opacity?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const DEFAULT_STYLE: TextStyle = {
|
|
138
|
+
font: F.inter,
|
|
139
|
+
weight: 500,
|
|
140
|
+
size: 0.042,
|
|
141
|
+
lineHeight: 1.3,
|
|
142
|
+
letterSpacing: 0,
|
|
143
|
+
color: '#ffffff',
|
|
144
|
+
highlight: '#ffffff',
|
|
145
|
+
highlightMode: 'color',
|
|
146
|
+
align: 'center',
|
|
147
|
+
transform: 'none',
|
|
148
|
+
maxLines: 2,
|
|
149
|
+
shadow: null,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** A text element; `key` names the slide text it shows (default "headline"). */
|
|
153
|
+
export function text(o: TextOptions): TextElement {
|
|
154
|
+
const key = o.key ?? 'headline';
|
|
155
|
+
const style: TextStyle = {
|
|
156
|
+
font: o.font ?? DEFAULT_STYLE.font,
|
|
157
|
+
weight: o.weight ?? DEFAULT_STYLE.weight,
|
|
158
|
+
size: o.size ?? DEFAULT_STYLE.size,
|
|
159
|
+
lineHeight: o.lineHeight ?? DEFAULT_STYLE.lineHeight,
|
|
160
|
+
letterSpacing: o.letterSpacing ?? DEFAULT_STYLE.letterSpacing,
|
|
161
|
+
color: o.color ?? DEFAULT_STYLE.color,
|
|
162
|
+
highlight: o.highlight ?? o.color ?? DEFAULT_STYLE.highlight,
|
|
163
|
+
highlightMode: o.highlightMode ?? DEFAULT_STYLE.highlightMode,
|
|
164
|
+
align: o.align ?? DEFAULT_STYLE.align,
|
|
165
|
+
transform: o.transform ?? DEFAULT_STYLE.transform,
|
|
166
|
+
maxLines: o.maxLines ?? DEFAULT_STYLE.maxLines,
|
|
167
|
+
shadow: o.shadow ?? null,
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
id: o.id ?? key,
|
|
171
|
+
kind: 'text',
|
|
172
|
+
x: o.x ?? 0.5,
|
|
173
|
+
y: o.y ?? 0.06,
|
|
174
|
+
rotation: o.rotation ?? 0,
|
|
175
|
+
opacity: o.opacity ?? 1,
|
|
176
|
+
key,
|
|
177
|
+
width: o.width ?? 0.86,
|
|
178
|
+
valign: o.valign ?? 'top',
|
|
179
|
+
style,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A template's type system: fonts, colours and the headline / subtitle / eyebrow presets. */
|
|
184
|
+
export interface TypeSpec {
|
|
185
|
+
heading: string;
|
|
186
|
+
body: string;
|
|
187
|
+
/** Headline colour. */
|
|
188
|
+
ink: Color;
|
|
189
|
+
/** Subtitle colour. */
|
|
190
|
+
soft: Color;
|
|
191
|
+
/** Highlight colour (*words*). */
|
|
192
|
+
accent: Color;
|
|
193
|
+
mode?: TextStyle['highlightMode'];
|
|
194
|
+
align?: TextStyle['align'];
|
|
195
|
+
head?: Partial<TextStyle>;
|
|
196
|
+
sub?: Partial<TextStyle>;
|
|
197
|
+
eyebrow?: Partial<TextStyle>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface Typeset {
|
|
201
|
+
spec: TypeSpec;
|
|
202
|
+
headline(o?: TextOptions): TextElement;
|
|
203
|
+
subtitle(o?: TextOptions): TextElement;
|
|
204
|
+
eyebrow(o?: TextOptions): TextElement;
|
|
205
|
+
/** Body-styled text for any other key. */
|
|
206
|
+
body(key: string, o?: TextOptions): TextElement;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function typeset(spec: TypeSpec): Typeset {
|
|
210
|
+
const align = spec.align ?? 'center';
|
|
211
|
+
const head: TextOptions = {
|
|
212
|
+
key: 'headline',
|
|
213
|
+
font: spec.heading,
|
|
214
|
+
weight: 800,
|
|
215
|
+
size: 0.088,
|
|
216
|
+
lineHeight: 1.1,
|
|
217
|
+
letterSpacing: -0.02,
|
|
218
|
+
color: spec.ink,
|
|
219
|
+
highlight: spec.accent,
|
|
220
|
+
highlightMode: spec.mode ?? 'color',
|
|
221
|
+
align,
|
|
222
|
+
maxLines: 2,
|
|
223
|
+
width: 0.86,
|
|
224
|
+
...spec.head,
|
|
225
|
+
};
|
|
226
|
+
const sub: TextOptions = {
|
|
227
|
+
key: 'subtitle',
|
|
228
|
+
font: spec.body,
|
|
229
|
+
weight: 500,
|
|
230
|
+
size: 0.042,
|
|
231
|
+
lineHeight: 1.32,
|
|
232
|
+
color: spec.soft,
|
|
233
|
+
highlight: spec.accent,
|
|
234
|
+
align,
|
|
235
|
+
maxLines: 2,
|
|
236
|
+
width: 0.8,
|
|
237
|
+
...spec.sub,
|
|
238
|
+
};
|
|
239
|
+
const brow: TextOptions = {
|
|
240
|
+
key: 'eyebrow',
|
|
241
|
+
font: spec.body,
|
|
242
|
+
weight: 700,
|
|
243
|
+
size: 0.03,
|
|
244
|
+
lineHeight: 1.2,
|
|
245
|
+
letterSpacing: 0.16,
|
|
246
|
+
transform: 'uppercase',
|
|
247
|
+
color: spec.accent,
|
|
248
|
+
highlight: spec.ink,
|
|
249
|
+
align,
|
|
250
|
+
maxLines: 1,
|
|
251
|
+
width: 0.8,
|
|
252
|
+
...spec.eyebrow,
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
spec,
|
|
256
|
+
headline: (o = {}) => text({ ...head, ...o }),
|
|
257
|
+
subtitle: (o = {}) => text({ ...sub, ...o }),
|
|
258
|
+
eyebrow: (o = {}) => text({ ...brow, ...o }),
|
|
259
|
+
body: (key, o = {}) => text({ ...sub, id: key, key, ...o }),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface CopyOptions {
|
|
264
|
+
cls?: Cls;
|
|
265
|
+
/**
|
|
266
|
+
* The seam between headline and subtitle, fraction of the canvas height:
|
|
267
|
+
* the headline grows upwards from it and the subtitle downwards, so one-
|
|
268
|
+
* and two-line headlines both keep the pair tight.
|
|
269
|
+
*/
|
|
270
|
+
seam: number;
|
|
271
|
+
/** Space between the two blocks, u (default 0.03). */
|
|
272
|
+
gap?: number;
|
|
273
|
+
x?: number;
|
|
274
|
+
width?: number;
|
|
275
|
+
subWidth?: number;
|
|
276
|
+
align?: TextStyle['align'];
|
|
277
|
+
head?: TextOptions;
|
|
278
|
+
sub?: TextOptions;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Headline + subtitle meeting at a seam (see CopyOptions.seam). */
|
|
282
|
+
export function copyAt(t: Typeset, o: CopyOptions): TextElement[] {
|
|
283
|
+
const { hu } = canvas(o.cls ?? 'tall');
|
|
284
|
+
const half = (o.gap ?? 0.03) / hu / 2;
|
|
285
|
+
const shared: TextOptions = { ...(o.x !== undefined ? { x: o.x } : {}), ...(o.align ? { align: o.align } : {}) };
|
|
286
|
+
const subWidth = o.subWidth ?? o.width;
|
|
287
|
+
return [
|
|
288
|
+
t.headline({ ...shared, ...(o.width !== undefined ? { width: o.width } : {}), y: r5(o.seam - half), valign: 'bottom', ...o.head }),
|
|
289
|
+
t.subtitle({ ...shared, ...(subWidth !== undefined ? { width: subWidth } : {}), y: r5(o.seam + half), valign: 'top', ...o.sub }),
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ------------------------------------------------------------------ */
|
|
294
|
+
/* Devices */
|
|
295
|
+
/* ------------------------------------------------------------------ */
|
|
296
|
+
|
|
297
|
+
export interface DeviceOptions {
|
|
298
|
+
id?: string;
|
|
299
|
+
frame?: DeviceFrameId;
|
|
300
|
+
color?: Color;
|
|
301
|
+
finish?: FrameFinish;
|
|
302
|
+
bezel?: 'black' | 'match';
|
|
303
|
+
x?: number;
|
|
304
|
+
/** Centre, fraction of the canvas height. */
|
|
305
|
+
y?: number;
|
|
306
|
+
/** Top edge (unrotated), fraction of the canvas height; overrides `y`. */
|
|
307
|
+
top?: number;
|
|
308
|
+
/** Bottom edge (unrotated), fraction of the canvas height; overrides `y` and `top`. */
|
|
309
|
+
bottom?: number;
|
|
310
|
+
/** Canvas the position is computed for (default "tall"). */
|
|
311
|
+
cls?: Cls;
|
|
312
|
+
width?: number;
|
|
313
|
+
rotation?: number;
|
|
314
|
+
opacity?: number;
|
|
315
|
+
slot?: string;
|
|
316
|
+
cornerRadius?: number;
|
|
317
|
+
shadow?: Shadow | null;
|
|
318
|
+
/** Mock app to show in template previews (the builder orders elements to get it). */
|
|
319
|
+
app?: MockArchetypeId;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Mock app hints per device element, read by `template()`; never stored in the template. */
|
|
323
|
+
const APP_HINTS = new WeakMap<SlideElement, MockArchetypeId>();
|
|
324
|
+
|
|
325
|
+
const DEFAULT_DEVICE_SHADOW = (): Shadow => shadow('#0000004d', 0.06, 0.03);
|
|
326
|
+
|
|
327
|
+
export function device(o: DeviceOptions = {}): DeviceElement {
|
|
328
|
+
const frame = o.frame ?? 'iphone';
|
|
329
|
+
const width = o.width ?? 0.78;
|
|
330
|
+
const cls = o.cls ?? 'tall';
|
|
331
|
+
const { hu } = canvas(cls);
|
|
332
|
+
const h = deviceHeight(cls, frame, width) / hu;
|
|
333
|
+
let y = o.y ?? 0.62;
|
|
334
|
+
if (o.bottom !== undefined) y = o.bottom - h / 2;
|
|
335
|
+
else if (o.top !== undefined) y = o.top + h / 2;
|
|
336
|
+
const slot = o.slot ?? 'main';
|
|
337
|
+
const el: DeviceElement = {
|
|
338
|
+
id: o.id ?? (slot === 'main' ? 'device' : `device-${slot}`),
|
|
339
|
+
kind: 'device',
|
|
340
|
+
x: o.x ?? 0.5,
|
|
341
|
+
y: r5(y),
|
|
342
|
+
rotation: o.rotation ?? 0,
|
|
343
|
+
opacity: o.opacity ?? 1,
|
|
344
|
+
frame,
|
|
345
|
+
style: { color: o.color ?? '#2b2c31', finish: o.finish ?? 'metal', bezel: o.bezel ?? 'black' },
|
|
346
|
+
width,
|
|
347
|
+
slot,
|
|
348
|
+
cornerRadius: o.cornerRadius ?? 0.06,
|
|
349
|
+
shadow: o.shadow === undefined ? DEFAULT_DEVICE_SHADOW() : o.shadow,
|
|
350
|
+
};
|
|
351
|
+
if (o.app) APP_HINTS.set(el, o.app);
|
|
352
|
+
return el;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Frame colours used across templates. */
|
|
356
|
+
export const METAL = {
|
|
357
|
+
graphite: '#2b2c31',
|
|
358
|
+
black: '#151518',
|
|
359
|
+
silver: '#e4e4e7',
|
|
360
|
+
white: '#f4f3f0',
|
|
361
|
+
titanium: '#b9b2a6',
|
|
362
|
+
desert: '#c4a688',
|
|
363
|
+
blue: '#3b4b66',
|
|
364
|
+
deepBlue: '#27324a',
|
|
365
|
+
gold: '#d9bf8c',
|
|
366
|
+
} as const;
|
|
367
|
+
|
|
368
|
+
/* ------------------------------------------------------------------ */
|
|
369
|
+
/* Shapes */
|
|
370
|
+
/* ------------------------------------------------------------------ */
|
|
371
|
+
|
|
372
|
+
export interface ShapeOptions {
|
|
373
|
+
id?: string;
|
|
374
|
+
/** Name shown in the editor's layers list ("Sun", "Neon tube"); defaults to the shape kind. */
|
|
375
|
+
name?: string;
|
|
376
|
+
x?: number;
|
|
377
|
+
y?: number;
|
|
378
|
+
/** Sets width and height together. */
|
|
379
|
+
size?: number;
|
|
380
|
+
width?: number;
|
|
381
|
+
height?: number;
|
|
382
|
+
rotation?: number;
|
|
383
|
+
opacity?: number;
|
|
384
|
+
fill?: Fill;
|
|
385
|
+
/** Solid fill colour (shorthand for `fill`). */
|
|
386
|
+
color?: Color;
|
|
387
|
+
radius?: number;
|
|
388
|
+
stroke?: number;
|
|
389
|
+
blur?: number;
|
|
390
|
+
seed?: number;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function shape(kind: ShapeKind, o: ShapeOptions, defaults: Partial<ShapeElement> = {}): ShapeElement {
|
|
394
|
+
const size = o.size ?? 0.4;
|
|
395
|
+
return {
|
|
396
|
+
id: o.id ?? '',
|
|
397
|
+
...(o.name ? { name: o.name } : {}),
|
|
398
|
+
kind: 'shape',
|
|
399
|
+
x: o.x ?? 0.5,
|
|
400
|
+
y: o.y ?? 0.5,
|
|
401
|
+
rotation: o.rotation ?? 0,
|
|
402
|
+
opacity: o.opacity ?? defaults.opacity ?? 1,
|
|
403
|
+
shape: kind,
|
|
404
|
+
width: o.width ?? size,
|
|
405
|
+
height: o.height ?? size,
|
|
406
|
+
fill: o.fill ?? solid(o.color ?? '#ffffff'),
|
|
407
|
+
radius: o.radius ?? defaults.radius ?? 0,
|
|
408
|
+
stroke: o.stroke ?? defaults.stroke ?? 0,
|
|
409
|
+
blur: o.blur ?? defaults.blur ?? 0,
|
|
410
|
+
seed: o.seed ?? defaults.seed ?? 0,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export const circle = (o: ShapeOptions): ShapeElement => shape('circle', o);
|
|
415
|
+
export const rect = (o: ShapeOptions): ShapeElement => shape('rect', o, { radius: 0.2 });
|
|
416
|
+
export const ring = (o: ShapeOptions): ShapeElement => shape('ring', o, { stroke: 0.012 });
|
|
417
|
+
export const blob = (o: ShapeOptions): ShapeElement => shape('blob', o, { seed: 7 });
|
|
418
|
+
/** A soft light: a blurred circle. */
|
|
419
|
+
export const glow = (o: ShapeOptions): ShapeElement => shape('circle', o, { blur: 0.14, opacity: 0.85 });
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Panorama decorations live on the strip of all slides side by side: `X`
|
|
423
|
+
* is measured in slide widths from the left edge of the first slide (1.0 =
|
|
424
|
+
* the seam between slides 1 and 2). Returns what slide `slideIndex` shows of
|
|
425
|
+
* a decoration `halfWidth` slide widths wide: the same shape at the slide's
|
|
426
|
+
* local x, or nothing when it is out of reach. Neighbouring slides cut the
|
|
427
|
+
* shape at the same seam, so it continues across the store listing.
|
|
428
|
+
*/
|
|
429
|
+
export function onStrip(slideIndex: number, X: number, halfWidth: number, make: (x: number) => SlideElement[]): SlideElement[] {
|
|
430
|
+
const x = r5(X - slideIndex);
|
|
431
|
+
return x + halfWidth > 0 && x - halfWidth < 1 ? make(x) : [];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ------------------------------------------------------------------ */
|
|
435
|
+
/* Theme, slides, templates */
|
|
436
|
+
/* ------------------------------------------------------------------ */
|
|
437
|
+
|
|
438
|
+
export interface ThemeOptions {
|
|
439
|
+
background: Background;
|
|
440
|
+
heading: string;
|
|
441
|
+
body: string;
|
|
442
|
+
/** Accent first (mock screens use it), then supporting colours, then neutrals. */
|
|
443
|
+
palette: Color[];
|
|
444
|
+
mockDark?: boolean;
|
|
445
|
+
panorama?: boolean;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function theme(o: ThemeOptions): Theme {
|
|
449
|
+
return {
|
|
450
|
+
background: o.background,
|
|
451
|
+
panorama: o.panorama ?? false,
|
|
452
|
+
fonts: { heading: o.heading, body: o.body },
|
|
453
|
+
palette: o.palette,
|
|
454
|
+
mockDark: o.mockDark ?? false,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Sample copy per language: en-US and ar-SA always, de-DE for a few templates. */
|
|
459
|
+
export interface Copy {
|
|
460
|
+
en: Record<string, string>;
|
|
461
|
+
ar: Record<string, string>;
|
|
462
|
+
de?: Record<string, string>;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export interface SlideSpec {
|
|
466
|
+
copy: Copy;
|
|
467
|
+
tall: SlideElement[];
|
|
468
|
+
tablet?: SlideElement[];
|
|
469
|
+
wide?: SlideElement[];
|
|
470
|
+
/** Slide-level background for every class layout (null = the theme background). */
|
|
471
|
+
background?: Background | null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Builds a slide's elements for one canvas class. */
|
|
475
|
+
export type Build = (cls: Cls) => SlideElement[];
|
|
476
|
+
|
|
477
|
+
/** A slide whose class layouts come from one build function (tall only unless `classes` says more). */
|
|
478
|
+
export function slide(copy: Copy, build: Build, o: { classes?: readonly Cls[]; background?: Background | null } = {}): SlideSpec {
|
|
479
|
+
const classes = o.classes ?? ['tall'];
|
|
480
|
+
const spec: SlideSpec = { copy, tall: build('tall') };
|
|
481
|
+
if (classes.includes('tablet')) spec.tablet = build('tablet');
|
|
482
|
+
if (classes.includes('wide')) spec.wide = build('wide');
|
|
483
|
+
if (o.background) spec.background = o.background;
|
|
484
|
+
return spec;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Every class layout: templates that design tablet and wide canvases explicitly. */
|
|
488
|
+
export const ALL_CLASSES: readonly Cls[] = ['tall', 'tablet', 'wide'];
|
|
489
|
+
|
|
490
|
+
export interface TemplateSpec {
|
|
491
|
+
id: string;
|
|
492
|
+
name: string;
|
|
493
|
+
description: string;
|
|
494
|
+
category: TemplateCategory;
|
|
495
|
+
tags: string[];
|
|
496
|
+
theme: Theme;
|
|
497
|
+
slides: SlideSpec[];
|
|
498
|
+
/**
|
|
499
|
+
* Mock apps that fit the template's story, best first. Template previews
|
|
500
|
+
* show generated app screens; the builder picks, per slide, a reachable
|
|
501
|
+
* app from this list (preferring ones not shown yet) so the copy and the
|
|
502
|
+
* screens tell one story. Devices may also name their own `app`.
|
|
503
|
+
*/
|
|
504
|
+
apps?: MockArchetypeId[];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Problems found while building templates (empty when every design is consistent); tests assert on it. */
|
|
508
|
+
export const BUILD_PROBLEMS: string[] = [];
|
|
509
|
+
|
|
510
|
+
/** The mock app the device at `elementIndex` of slide `slideIndex` shows (the renderer's own seed). */
|
|
511
|
+
export function mockAppAt(slideIndex: number, elementIndex: number): MockArchetypeId {
|
|
512
|
+
return mockArchetype(mockSeed(slideIndex, elementIndex));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** An oriented box in u: centre, half extents, rotation (radians). */
|
|
516
|
+
interface Obb {
|
|
517
|
+
cx: number;
|
|
518
|
+
cy: number;
|
|
519
|
+
hw: number;
|
|
520
|
+
hh: number;
|
|
521
|
+
a: number;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** A box `w` x `h` centred on (cx, cy), rotated, grown by local margins (top, right, bottom, left). */
|
|
525
|
+
function obb(cx: number, cy: number, w: number, h: number, rotation: number, m: [number, number, number, number] = [0, 0, 0, 0]): Obb {
|
|
526
|
+
const a = (rotation * Math.PI) / 180;
|
|
527
|
+
const [top, right, bottom, left] = m;
|
|
528
|
+
// Asymmetric margins move the centre (in the box's own frame).
|
|
529
|
+
const dx = (right - left) / 2;
|
|
530
|
+
const dy = (bottom - top) / 2;
|
|
531
|
+
return { cx: cx + dx * Math.cos(a) - dy * Math.sin(a), cy: cy + dx * Math.sin(a) + dy * Math.cos(a), hw: w / 2 + (left + right) / 2, hh: h / 2 + (top + bottom) / 2, a };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Painted extent of an element on a class canvas, in u. Device shadows and
|
|
536
|
+
* glow halos are included (a blur of b reaches about b beyond the shape);
|
|
537
|
+
* texts count with their tallest block (maxLines lines), so a longer text a
|
|
538
|
+
* user types later still fits the measured area.
|
|
539
|
+
*/
|
|
540
|
+
function elementObb(el: SlideElement, cls: Cls, effects = true): Obb {
|
|
541
|
+
const { wu, hu } = canvas(cls);
|
|
542
|
+
const cx = el.x * wu;
|
|
543
|
+
const cy = el.y * hu;
|
|
544
|
+
switch (el.kind) {
|
|
545
|
+
case 'device': {
|
|
546
|
+
const s = effects ? el.shadow : null;
|
|
547
|
+
const m: [number, number, number, number] = s ? [Math.max(0, s.blur - s.y), s.blur + Math.max(0, s.x), s.blur + Math.max(0, s.y), s.blur + Math.max(0, -s.x)] : [0, 0, 0, 0];
|
|
548
|
+
return obb(cx, cy, el.width, deviceHeight(cls, el.frame, el.width), el.rotation, m);
|
|
549
|
+
}
|
|
550
|
+
case 'shape': {
|
|
551
|
+
const b = effects ? el.blur : 0;
|
|
552
|
+
return obb(cx, cy, el.width, el.height, el.rotation, [b, b, b, b]);
|
|
553
|
+
}
|
|
554
|
+
case 'text': {
|
|
555
|
+
const h = (el.style.maxLines > 0 ? el.style.maxLines : 3) * el.style.size * el.style.lineHeight;
|
|
556
|
+
const top = el.valign === 'top' ? cy : el.valign === 'bottom' ? cy - h : cy - h / 2;
|
|
557
|
+
return obb(cx, top + h / 2, el.width, h, el.rotation);
|
|
558
|
+
}
|
|
559
|
+
case 'image':
|
|
560
|
+
return obb(cx, cy, el.width, el.width, el.rotation);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Separating axis test for two oriented boxes. */
|
|
565
|
+
function obbOverlap(p: Obb, q: Obb): boolean {
|
|
566
|
+
const axes = [p.a, p.a + Math.PI / 2, q.a, q.a + Math.PI / 2];
|
|
567
|
+
const dx = q.cx - p.cx;
|
|
568
|
+
const dy = q.cy - p.cy;
|
|
569
|
+
for (const t of axes) {
|
|
570
|
+
const ux = Math.cos(t);
|
|
571
|
+
const uy = Math.sin(t);
|
|
572
|
+
const reach = (b: Obb) => Math.abs(b.hw * (Math.cos(b.a) * ux + Math.sin(b.a) * uy)) + Math.abs(b.hh * (-Math.sin(b.a) * ux + Math.cos(b.a) * uy));
|
|
573
|
+
if (Math.abs(dx * ux + dy * uy) >= reach(p) + reach(q)) return false;
|
|
574
|
+
}
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* True when two elements on a class canvas may touch: their painted areas
|
|
580
|
+
* (with shadows and glows), or with `effects: false` their bodies only.
|
|
581
|
+
* `margin` (u) grows both boxes, to demand clear space between them.
|
|
582
|
+
*/
|
|
583
|
+
export function elementsOverlap(a: SlideElement, b: SlideElement, cls: Cls, o: { effects?: boolean; margin?: number } = {}): boolean {
|
|
584
|
+
const grow = (box: Obb): Obb => ({ ...box, hw: box.hw + (o.margin ?? 0) / 2, hh: box.hh + (o.margin ?? 0) / 2 });
|
|
585
|
+
return obbOverlap(grow(elementObb(a, cls, o.effects ?? true)), grow(elementObb(b, cls, o.effects ?? true)));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Painter's order constraints: element `a` must stay below `b` when `a` comes
|
|
590
|
+
* first and their painted bounds overlap. Any order that keeps every such
|
|
591
|
+
* pair looks exactly the same, so devices can move through everything they
|
|
592
|
+
* do not touch.
|
|
593
|
+
*/
|
|
594
|
+
function overlapGraph(elements: SlideElement[], cls: Cls): boolean[][] {
|
|
595
|
+
const boxes = elements.map((e) => elementObb(e, cls));
|
|
596
|
+
return boxes.map((a, i) => boxes.map((b, j) => i < j && obbOverlap(a, b)));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Indices of the elements that must stay below (`dir` = -1) or above (+1) element `k`. */
|
|
600
|
+
function closure(edges: boolean[][], k: number, dir: -1 | 1): Set<number> {
|
|
601
|
+
const out = new Set<number>();
|
|
602
|
+
const stack = [k];
|
|
603
|
+
while (stack.length) {
|
|
604
|
+
const v = stack.pop() as number;
|
|
605
|
+
for (let u = 0; u < edges.length; u++) {
|
|
606
|
+
const linked = dir < 0 ? edges[u][v] : edges[v][u];
|
|
607
|
+
if (linked && !out.has(u)) {
|
|
608
|
+
out.add(u);
|
|
609
|
+
stack.push(u);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Re-orders `elements` so the device at `from` lands at index `to` (which
|
|
618
|
+
* must lie in its free range, see deviceRange): the elements it must stay
|
|
619
|
+
* above, plus enough unconstrained ones in their current order, go first.
|
|
620
|
+
*/
|
|
621
|
+
function placeDevice(elements: SlideElement[], from: number, to: number, edges: boolean[][]): SlideElement[] {
|
|
622
|
+
const below = closure(edges, from, -1);
|
|
623
|
+
const above = closure(edges, from, 1);
|
|
624
|
+
const free = elements.map((_, i) => i).filter((i) => i !== from && !below.has(i) && !above.has(i));
|
|
625
|
+
const freeBefore = free.filter((i) => i < from);
|
|
626
|
+
const freeAfter = free.filter((i) => i > from);
|
|
627
|
+
let chosen: number[];
|
|
628
|
+
const need = to - below.size;
|
|
629
|
+
// Dropping the last ones keeps the set closed downwards (constraints only point forwards), and so does adding the first ones.
|
|
630
|
+
if (need <= freeBefore.length) chosen = freeBefore.slice(0, need);
|
|
631
|
+
else chosen = [...freeBefore, ...freeAfter.slice(0, need - freeBefore.length)];
|
|
632
|
+
const first = new Set<number>([...below, ...chosen]);
|
|
633
|
+
const head = elements.filter((_, i) => first.has(i));
|
|
634
|
+
const tail = elements.filter((_, i) => i !== from && !first.has(i));
|
|
635
|
+
return [...head, elements[from], ...tail];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** The element indices a device can take without changing the picture: [elements it must stay above, n - 1 - elements it must stay below]. */
|
|
639
|
+
function deviceRange(edges: boolean[][], from: number): [number, number] {
|
|
640
|
+
return [closure(edges, from, -1).size, edges.length - 1 - closure(edges, from, 1).size];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Orders each device so its mock screen shows the first preferred app it
|
|
645
|
+
* can reach, preferring apps no earlier slide shows (`used`). `chosen` maps
|
|
646
|
+
* device ids to the app picked in the tall layout, so tablet and wide
|
|
647
|
+
* layouts show the same screen when they can.
|
|
648
|
+
*/
|
|
649
|
+
function steerMocks(
|
|
650
|
+
elements: SlideElement[],
|
|
651
|
+
slideIndex: number,
|
|
652
|
+
cls: Cls,
|
|
653
|
+
apps: readonly MockArchetypeId[],
|
|
654
|
+
used: Set<MockArchetypeId>,
|
|
655
|
+
chosen: Map<string, MockArchetypeId>,
|
|
656
|
+
previous: ReadonlySet<MockArchetypeId>,
|
|
657
|
+
): SlideElement[] {
|
|
658
|
+
let out = [...elements];
|
|
659
|
+
for (const el of elements) {
|
|
660
|
+
if (el.kind !== 'device') continue;
|
|
661
|
+
const hint = APP_HINTS.get(el);
|
|
662
|
+
const first = chosen.get(el.id) ?? hint;
|
|
663
|
+
const prefs = [...new Set([...(first ? [first] : []), ...apps])];
|
|
664
|
+
if (!prefs.length) continue;
|
|
665
|
+
const from = out.indexOf(el);
|
|
666
|
+
const edges = overlapGraph(out, cls);
|
|
667
|
+
const [lo, hi] = deviceRange(edges, from);
|
|
668
|
+
// Nearest index first, so a device only moves when it has to.
|
|
669
|
+
const options = new Map<MockArchetypeId, number>();
|
|
670
|
+
for (let d = 0; d <= hi - lo; d++) {
|
|
671
|
+
for (const i of [from - d, from + d]) {
|
|
672
|
+
if (i < lo || i > hi) continue;
|
|
673
|
+
const app = mockAppAt(slideIndex, i);
|
|
674
|
+
if (!options.has(app)) options.set(app, i);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const fresh = (a: MockArchetypeId) => options.has(a) && !previous.has(a);
|
|
678
|
+
const pick = chosen.has(el.id)
|
|
679
|
+
? prefs.find((a) => options.has(a))
|
|
680
|
+
: // An unused preferred app, else a preferred one the previous slide does not show, else any screen that differs from it.
|
|
681
|
+
(prefs.find((a) => fresh(a) && !used.has(a)) ?? prefs.find(fresh) ?? (previous.size ? [...options.keys()].find((a) => !previous.has(a)) : undefined) ?? prefs.find((a) => options.has(a)));
|
|
682
|
+
if (!pick) continue;
|
|
683
|
+
used.add(pick);
|
|
684
|
+
const to = options.get(pick) as number;
|
|
685
|
+
if (to !== from) out = placeDevice(out, from, to, edges);
|
|
686
|
+
}
|
|
687
|
+
// Record what each device finally shows (placing a later device can shift an earlier one).
|
|
688
|
+
out.forEach((el, i) => {
|
|
689
|
+
if (el.kind !== 'device') return;
|
|
690
|
+
const app = mockAppAt(slideIndex, i);
|
|
691
|
+
if (!chosen.has(el.id)) chosen.set(el.id, app);
|
|
692
|
+
used.add(app);
|
|
693
|
+
});
|
|
694
|
+
return out;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Gives auto-id shapes ("") ids like "glow-1" and reports duplicate ids. */
|
|
698
|
+
function assignIds(elements: SlideElement[], where: string): SlideElement[] {
|
|
699
|
+
const counters = new Map<string, number>();
|
|
700
|
+
const seen = new Set<string>();
|
|
701
|
+
return elements.map((el) => {
|
|
702
|
+
let id = el.id;
|
|
703
|
+
if (!id) {
|
|
704
|
+
const prefix = el.kind === 'shape' ? (el.shape === 'circle' && el.blur > 0 ? 'glow' : el.shape) : el.kind;
|
|
705
|
+
do {
|
|
706
|
+
const n = (counters.get(prefix) ?? 0) + 1;
|
|
707
|
+
counters.set(prefix, n);
|
|
708
|
+
id = `${prefix}-${n}`;
|
|
709
|
+
} while (seen.has(id));
|
|
710
|
+
}
|
|
711
|
+
if (seen.has(id)) BUILD_PROBLEMS.push(`${where}: duplicate element id "${id}"`);
|
|
712
|
+
seen.add(id);
|
|
713
|
+
return id === el.id ? el : { ...el, id };
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const LANG = { en: 'en-US', ar: 'ar-SA', de: 'de-DE' } as const;
|
|
718
|
+
|
|
719
|
+
/** A complete, valid Template from a spec. */
|
|
720
|
+
export function template(spec: TemplateSpec): Template {
|
|
721
|
+
const used = new Set<MockArchetypeId>();
|
|
722
|
+
let previous = new Set<MockArchetypeId>();
|
|
723
|
+
const slides: TemplateSlide[] = spec.slides.map((s, i) => {
|
|
724
|
+
const chosen = new Map<string, MockArchetypeId>();
|
|
725
|
+
const layout = (cls: Cls, elements: SlideElement[]): SlideLayout => {
|
|
726
|
+
const where = `${spec.id} slide ${i + 1} (${cls})`;
|
|
727
|
+
// Ids first: steering matches devices across class layouts by id.
|
|
728
|
+
const named = assignIds(elements, where);
|
|
729
|
+
named.forEach((el, k) => {
|
|
730
|
+
const hint = APP_HINTS.get(elements[k]);
|
|
731
|
+
if (hint) APP_HINTS.set(el, hint);
|
|
732
|
+
});
|
|
733
|
+
const steered = steerMocks(named, i, cls, spec.apps ?? [], used, chosen, previous);
|
|
734
|
+
// Copies: builder objects shared between layouts must not alias inside the template.
|
|
735
|
+
return { background: s.background ? deepClone(s.background) : null, elements: deepClone(steered) };
|
|
736
|
+
};
|
|
737
|
+
const texts: TemplateSlide['texts'] = {};
|
|
738
|
+
for (const [short, code] of Object.entries(LANG) as [keyof Copy, string][]) {
|
|
739
|
+
const copy = s.copy[short];
|
|
740
|
+
if (copy) texts[code] = { ...copy };
|
|
741
|
+
}
|
|
742
|
+
const layouts: TemplateSlide['layouts'] = { tall: layout('tall', s.tall) };
|
|
743
|
+
if (s.tablet) layouts.tablet = layout('tablet', s.tablet);
|
|
744
|
+
if (s.wide) layouts.wide = layout('wide', s.wide);
|
|
745
|
+
previous = new Set(chosen.values());
|
|
746
|
+
return { texts, layouts };
|
|
747
|
+
});
|
|
748
|
+
return {
|
|
749
|
+
format: 'wajha.template',
|
|
750
|
+
version: 1,
|
|
751
|
+
id: spec.id,
|
|
752
|
+
name: spec.name,
|
|
753
|
+
author: 'Wajha',
|
|
754
|
+
description: spec.description,
|
|
755
|
+
category: spec.category,
|
|
756
|
+
tags: spec.tags,
|
|
757
|
+
theme: deepClone(spec.theme),
|
|
758
|
+
slides,
|
|
759
|
+
assets: {},
|
|
760
|
+
};
|
|
761
|
+
}
|