@appshoteditor/shot-dsl 0.3.0 → 0.4.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/src/compose.ts CHANGED
@@ -1,7 +1,25 @@
1
- import type { BackgroundJSON, LayerJSON, Template } from './types';
2
- import { makeTextLayer, makeScreen, makeTemplate } from './builders';
1
+ import type { BackgroundJSON, ColorStop, LayerJSON, Template } from './types';
2
+ import { makeTextLayer, makeShapeLayer, makeScreen, makeTemplate } from './builders';
3
3
  import { makeDeviceFrameLayer } from './frames';
4
4
  import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
5
+ import { generateLayerId } from './validate';
6
+ import {
7
+ NO_TANGENT,
8
+ focusCorners,
9
+ focusReach,
10
+ focusXExtent,
11
+ inTangentZone,
12
+ rectsOverlap,
13
+ rotatedBox,
14
+ solveVertical,
15
+ subjectPointToCanvas,
16
+ visibleXExtent,
17
+ type BleedPreference,
18
+ type FocusBand,
19
+ type Subject,
20
+ type VerticalResult
21
+ } from './layout-system';
22
+ import { darken, isHexColor, lighten, mixHex, readableTextOn, rgba } from './color';
5
23
 
6
24
  /**
7
25
  * Editor-unit canvas dimensions for a device, by frame class — so a plan that mixes iPhone, iPad,
@@ -9,7 +27,7 @@ import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
9
27
  * Page Setup presets (phone 280×608, tablet 450×600, laptop/desktop 608×380). Export scales these
10
28
  * up to the real App Store pixel sizes.
11
29
  */
12
- function canvasDimsForDevice(deviceId: string): { width: number; height: number } {
30
+ export function canvasDimsForDevice(deviceId: string): { width: number; height: number } {
13
31
  switch (getDeviceFrame(deviceId)?.category) {
14
32
  case 'tablet':
15
33
  return { width: 450, height: 600 };
@@ -24,15 +42,82 @@ function canvasDimsForDevice(deviceId: string): { width: number; height: number
24
42
 
25
43
  /**
26
44
  * Screen layout variants:
27
- * - `text-top` (default): headline (+ subheadline) at the top, device below, bleeding off the bottom.
28
- * - `text-bottom`: device at the top bleeding off the TOP edge, text block anchored to the bottom.
29
- * - `device-bleed`: text at the top, an oversized device (~95% of the width) bleeding heavily off
30
- * the bottom — the "hero" look.
45
+ * - `text-top` (default): headline (+ subheadline) at the top, device below.
46
+ * - `text-bottom`: device at the top (bleeding off the TOP edge when it bleeds), text block at the bottom.
47
+ * - `device-bleed`: text at the top, the device (≤ 90% of the width) pushed low and bleeding more — the "hero" look.
31
48
  */
32
49
  export type ComposeLayout = 'text-top' | 'text-bottom' | 'device-bleed';
33
-
34
50
  export const COMPOSE_LAYOUTS: readonly ComposeLayout[] = ['text-top', 'text-bottom', 'device-bleed'];
35
51
 
52
+ /**
53
+ * How a screenshot is presented:
54
+ * - `device` (default): inside a device frame mockup.
55
+ * - `frameless`: the bare screenshot with rounded corners + a soft shadow.
56
+ * - `zoom`: a magnified crop of the screen's selling UI (`crop`, else the `focus` band), rounded + shadow.
57
+ */
58
+ export type ComposePresentation = 'device' | 'frameless' | 'zoom';
59
+ export const COMPOSE_PRESENTATIONS: readonly ComposePresentation[] = ['device', 'frameless', 'zoom'];
60
+
61
+ /** `auto` (default): the no-tangent rule picks clear-with-margin or a decisive bleed. */
62
+ export type ComposeBleed = BleedPreference;
63
+ export const COMPOSE_BLEEDS: readonly ComposeBleed[] = ['auto', 'none', 'deep'];
64
+
65
+ /** Fonts the editor offers (Inter is web-loaded; the rest are system fonts). */
66
+ export const COMPOSE_FONTS: readonly string[] = [
67
+ 'Inter',
68
+ 'Arial',
69
+ 'Helvetica',
70
+ 'Georgia',
71
+ 'Times New Roman',
72
+ 'Courier New',
73
+ 'Verdana',
74
+ 'Trebuchet MS',
75
+ 'Impact',
76
+ 'Comic Sans MS'
77
+ ];
78
+
79
+ /** Fractions (0–1) of the screenshot: a crop rectangle for `zoom`. */
80
+ export interface ComposeCrop {
81
+ x: number;
82
+ y: number;
83
+ w: number;
84
+ h: number;
85
+ }
86
+
87
+ /** Set-wide background system, used for screens that omit `background`. */
88
+ export interface ComposePalette {
89
+ /** `family`: every screen shares one gradient through `colors`. `sequence`: screen i uses colors[i % n]. */
90
+ mode: 'family' | 'sequence';
91
+ colors: string[];
92
+ }
93
+
94
+ export interface ComposePanorama {
95
+ /** Runs of adjacent screen indices sharing one continuous background, e.g. [[0, 1], [2, 3]]. */
96
+ spans: number[][];
97
+ /**
98
+ * Devices that cross a seam into the next screen: `true` = the first screen of EVERY span, or a
99
+ * list of screen indices (each must be the first screen of a span). Default none. More than one
100
+ * straddle per set is allowed but lint-warned (`panorama-straddle-count`).
101
+ */
102
+ straddle?: boolean | number[];
103
+ /** Soft decorative circles straddling each seam. Default `orbs`. */
104
+ decoration?: 'orbs' | 'none';
105
+ }
106
+
107
+ /** Plan-level style (all optional; an empty style composes exactly like a plan without one). */
108
+ export interface ComposeStyle {
109
+ presentation?: ComposePresentation;
110
+ /** Degrees (clockwise) for the accented screens listed in `tiltScreens`. Default 0. */
111
+ tilt?: number;
112
+ /** Screen indices that get `tilt`. Default: none. */
113
+ tiltScreens?: number[];
114
+ bleed?: ComposeBleed;
115
+ palette?: ComposePalette;
116
+ panorama?: ComposePanorama;
117
+ /** Font family for all text (one of COMPOSE_FONTS). Default Inter. */
118
+ font?: string;
119
+ }
120
+
36
121
  /** One screen's worth of plan input (the skill decides these per benefit). */
37
122
  export interface ComposeScreenPlan {
38
123
  headline: string;
@@ -43,9 +128,23 @@ export interface ComposeScreenPlan {
43
128
  subheadlineColor?: string;
44
129
  /** Defaults to `text-top`. */
45
130
  layout?: ComposeLayout;
46
- background: BackgroundJSON;
131
+ /**
132
+ * Required unless the plan has `style.palette`. In a panorama span every screen shows the span's
133
+ * FIRST screen's background.
134
+ */
135
+ background?: BackgroundJSON;
47
136
  screenshot: { url: string; width: number; height: number };
48
137
  deviceId: string;
138
+ /** Fractions of the screenshot height that must stay visible (the selling UI). */
139
+ focus?: FocusBand;
140
+ /** Zoom crop (fractions of the screenshot); defaults to the focus band. */
141
+ crop?: ComposeCrop;
142
+ /** Overrides `style.presentation` for this screen. */
143
+ presentation?: ComposePresentation;
144
+ /** Overrides the style tilt for this screen (degrees, clockwise). */
145
+ tilt?: number;
146
+ /** Short social-proof pill above the headline, e.g. "Teacher-approved". */
147
+ badge?: string;
49
148
  }
50
149
 
51
150
  export interface ComposePlan {
@@ -53,6 +152,59 @@ export interface ComposePlan {
53
152
  screens: ComposeScreenPlan[];
54
153
  canvasWidth?: number;
55
154
  canvasHeight?: number;
155
+ style?: ComposeStyle;
156
+ }
157
+
158
+ export interface ComposeWarning {
159
+ /** Screen index, when the warning is about one screen. */
160
+ screen?: number;
161
+ code:
162
+ | 'headline-words'
163
+ | 'headline-lines'
164
+ | 'subheadline-lines'
165
+ | 'tilt-count'
166
+ | 'panorama-seam'
167
+ | 'straddle-skipped'
168
+ | 'tilt-reduced'
169
+ | 'panorama-straddle-count'
170
+ | 'zoom-focus-cropped'
171
+ | 'badge-long';
172
+ message: string;
173
+ }
174
+
175
+ /** Per-screen geometry summary (canvas-relative), for reports and tests. */
176
+ export interface ComposeScreenMetrics {
177
+ index: number;
178
+ presentation: ComposePresentation;
179
+ layout: ComposeLayout;
180
+ /** Screens with the same group share scale, baseline and far-edge treatment. */
181
+ group: string;
182
+ /** Rendered subject scale (device scaleX; image scaleX for frameless/zoom). */
183
+ scale: number;
184
+ /** Rendered (unrotated) subject width ÷ W. */
185
+ widthFraction: number;
186
+ /** Subject's rotated bounding-box top / bottom ÷ H. */
187
+ top: number;
188
+ bottom: number;
189
+ /** Far-edge overshoot ÷ H (> 0 bleeds off the canvas). */
190
+ overshoot: number;
191
+ /** Far-edge overshoot ÷ the subject's rendered (rotated) height. */
192
+ bleedFraction: number;
193
+ mode: 'clear' | 'bleed';
194
+ /** True if the far edge sits in the forbidden "just touching" band (never, by construction). */
195
+ tangent: boolean;
196
+ /** Headline font size ÷ W. */
197
+ headlineSize: number;
198
+ /** Headline box top ÷ H (identical across a set by construction). */
199
+ headlineTop: number;
200
+ tilt: number;
201
+ /** Horizontal centre ÷ W (≠ 0.5 for a straddling panorama device). */
202
+ centerX: number;
203
+ }
204
+
205
+ export interface ComposeReport {
206
+ warnings: ComposeWarning[];
207
+ screens: ComposeScreenMetrics[];
56
208
  }
57
209
 
58
210
  // ---------------------------------------------------------------------------
@@ -66,20 +218,73 @@ export interface ComposePlan {
66
218
  * (tablet, laptop) it's capped by the height so text doesn't swallow a landscape canvas.
67
219
  */
68
220
  const TYPE_UNIT_HEIGHT_CAP = 0.55; // unit = min(W, 0.55·H)
69
- const HEADLINE_SIZE = 0.085; // × unit — for headlines that fit in ≤ 2 lines
70
- const HEADLINE_SIZE_LONG = 0.072; // × unit — fallback when the headline would wrap to 3+ lines
221
+ /** × unit — ONE headline size for the whole set (copy that doesn't fit is a lint warning, never a shrink). */
222
+ export const HEADLINE_SIZE = 0.085;
71
223
  const HEADLINE_LINE_HEIGHT = 1.1;
72
224
  const SUBHEADLINE_RATIO = 0.55; // subheadline size ÷ headline size
73
225
  const SUBHEADLINE_LINE_HEIGHT = 1.25;
74
226
  const SUBHEADLINE_OPACITY = 0.85;
75
227
  const TEXT_WIDTH = 0.84; // × W → 8% side padding each side
76
- /** Rough average glyph advance for Inter at heavy weights, as a fraction of the font size. */
77
- const AVG_CHAR_WIDTH = 0.58;
228
+ /**
229
+ * Conservative per-font text metrics for line-wrap estimates (and so the reserved text area):
230
+ * px per character "unit" (see `charUnits`; 1 unit ≈ an average lowercase letter) × the font size.
231
+ * Calibrated from node-canvas measurements of macOS system fonts (bold, mixed case, ALL CAPS,
232
+ * narrow/wide stress strings) + ~10% headroom, so an estimate of N lines never renders as N+1;
233
+ * see src/lib/utils/composeFonts.canvas.test.ts in the app. Inter (web font, not installed for the
234
+ * measurement) is set ~10% wider than Arial. Courier New is monospace: every character is 1 unit.
235
+ */
236
+ export const FONT_CHAR_WIDTH: Readonly<Record<string, number>> = {
237
+ Inter: 0.66,
238
+ Arial: 0.64,
239
+ Helvetica: 0.64,
240
+ Georgia: 0.72,
241
+ 'Times New Roman': 0.63,
242
+ 'Courier New': 0.66,
243
+ Verdana: 0.76,
244
+ 'Trebuchet MS': 0.68,
245
+ Impact: 0.64,
246
+ 'Comic Sans MS': 0.76
247
+ };
248
+ const MONOSPACE_FONTS = new Set(['Courier New']);
249
+ const AVG_CHAR_WIDTH = FONT_CHAR_WIDTH.Inter;
250
+
251
+ /** A font name (→ FONT_CHAR_WIDTH) or a raw px-per-unit factor. */
252
+ export type TextMetrics = string | number;
253
+ const metricsOf = (m: TextMetrics) =>
254
+ typeof m === 'number' ? { charWidth: m, mono: false } : { charWidth: FONT_CHAR_WIDTH[m] ?? AVG_CHAR_WIDTH, mono: MONOSPACE_FONTS.has(m) };
78
255
 
79
256
  const EDGE_MARGIN = 0.055; // × H — gap between the text block and the canvas edge
80
257
  const TEXT_GAP = 0.3; // × headline font size — headline ↔ subheadline gap
81
258
  const DEVICE_GAP = 0.04; // × unit — text block ↔ device gap
82
259
 
260
+ /** Copy rules (lint). */
261
+ export const COPY_RULES = { headlineMaxWords: 5, headlineMaxLines: 2, subheadlineMaxLines: 1, maxTiltedScreens: 2 } as const;
262
+
263
+ // Badge pill (social proof), reserved as a row above the headline set-wide when any screen has one.
264
+ const BADGE_FONT = 0.36; // × headline size
265
+ const BADGE_PAD_X = 1.1; // × badge font
266
+ const BADGE_HEIGHT = 2.1; // × badge font
267
+ const BADGE_GAP = 0.45; // × headline size — badge ↔ headline
268
+ const BADGE_MAX_CHARS = 28;
269
+
270
+ // Frameless / zoom styling.
271
+ const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel → a touch narrower)
272
+ const FRAMELESS_RADIUS = 0.1; // × rendered width
273
+ const ZOOM_WIDTH = 0.88; // × W
274
+ const ZOOM_RADIUS = 0.05; // × W
275
+ const ZOOM_MAX_MAG = 2; // focus-derived zoom: at most 2× the full-width fit
276
+ const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
277
+ const SHADOW = { color: 'rgba(0,0,0,0.28)', blur: 0.07, offsetY: 0.025 }; // blur/offset × W
278
+
279
+ // Panorama.
280
+ const STRADDLE_OVERLAP = 0.18; // × the device's rendered bounding-box width that crosses the seam
281
+ const STRADDLE_MIN = 0.06; // below this a straddle is not worth it (skipped + warning)
282
+ const MAX_STRADDLES = 1; // seam crossings per set before a lint warning
283
+ const ORB_RADIUS = 0.34; // × W
284
+ const ORB_Y = 0.7; // × H
285
+ const ORB_MIN_RADIUS = 0.08; // × W — smaller than this, the orb is dropped
286
+ const ORB_TEXT_GAP = 0.02; // × H — orbs keep this far from a text block
287
+
83
288
  interface LayoutSpec {
84
289
  /** Target rendered device width as a fraction of the canvas width. */
85
290
  deviceWidth: number;
@@ -92,146 +297,1014 @@ interface LayoutSpec {
92
297
  const LAYOUTS: Record<ComposeLayout, LayoutSpec> = {
93
298
  'text-top': { deviceWidth: 0.86, maxBleed: 0.2, minDeviceTop: 0 },
94
299
  'text-bottom': { deviceWidth: 0.9, maxBleed: 0.2, minDeviceTop: 0 },
95
- 'device-bleed': { deviceWidth: 0.95, maxBleed: 0.4, minDeviceTop: 0.28 }
300
+ // Capped at NO_TANGENT.maxWidth (90% W — a 5% side margin); the hero differs by sitting lower.
301
+ 'device-bleed': { deviceWidth: 0.9, maxBleed: 0.4, minDeviceTop: 0.28 }
96
302
  };
97
303
 
98
- /** Greedy word-wrap estimate of how many lines `text` takes at `fontSize` in a box `width` wide. */
99
- function estimateLines(text: string, fontSize: number, width: number): number {
100
- const maxChars = Math.max(1, Math.floor(width / (fontSize * AVG_CHAR_WIDTH)));
304
+ /**
305
+ * Relative advance of a character (1 = an average lowercase letter). Capitals and m/w are wide,
306
+ * i/l/punctuation narrow — so ALL-CAPS or "Mm Ww" copy isn't under-estimated.
307
+ */
308
+ function charUnits(ch: string): number {
309
+ if (ch === ' ') return 0.5;
310
+ if ("iljI.,:;!|'’".includes(ch)) return 0.5;
311
+ if ('ftr()[]-–'.includes(ch)) return 0.7;
312
+ if ('mwMW'.includes(ch)) return 1.55;
313
+ if (ch >= 'A' && ch <= 'Z') return 1.3;
314
+ return 1;
315
+ }
316
+
317
+ /** Estimated rendered width of `text` (single line) — the width model behind `estimateLines`. */
318
+ export function estimateTextWidth(text: string, fontSize: number, metrics: TextMetrics = 'Inter'): number {
319
+ const { charWidth, mono } = metricsOf(metrics);
320
+ return [...text].reduce((sum, ch) => sum + (mono ? 1 : charUnits(ch)), 0) * fontSize * charWidth;
321
+ }
322
+
323
+ /**
324
+ * Greedy word-wrap estimate (Fabric Textbox semantics: wrap at spaces, break over-long words) of how
325
+ * many lines `text` takes at `fontSize` in a box `width` wide, in the font `metrics` (a COMPOSE_FONTS
326
+ * name, or a raw px-per-unit factor).
327
+ */
328
+ export function estimateLines(text: string, fontSize: number, width: number, metrics: TextMetrics = 'Inter'): number {
329
+ const { charWidth, mono } = metricsOf(metrics);
330
+ const unit = fontSize * charWidth;
331
+ const maxUnits = Math.max(1, width / unit);
332
+ const cu = (ch: string) => (mono ? 1 : charUnits(ch));
333
+ const units = (w: string) => [...w].reduce((sum, ch) => sum + cu(ch), 0);
101
334
  let lines = 0;
102
335
  for (const paragraph of text.split('\n')) {
103
336
  let current = 0;
104
337
  lines++;
105
338
  for (const word of paragraph.split(/\s+/).filter(Boolean)) {
106
- const len = word.length;
339
+ const len = units(word);
107
340
  if (current === 0) {
108
341
  current = len;
109
- } else if (current + 1 + len <= maxChars) {
110
- current += 1 + len;
342
+ } else if (current + cu(' ') + len <= maxUnits) {
343
+ current += cu(' ') + len;
111
344
  } else {
112
345
  lines++;
113
346
  current = len;
114
347
  }
115
348
  // A single word longer than a line wraps mid-word in Fabric's Textbox.
116
- while (current > maxChars) {
349
+ while (current > maxUnits) {
117
350
  lines++;
118
- current -= maxChars;
351
+ current -= maxUnits;
119
352
  }
120
353
  }
121
354
  }
122
355
  return Math.max(1, lines);
123
356
  }
124
357
 
125
- interface TextBlock {
358
+ /** Set typography for one canvas size. */
359
+ interface Typography {
360
+ W: number;
361
+ H: number;
362
+ unit: number;
126
363
  headlineSize: number;
127
- headlineHeight: number;
128
364
  subSize: number;
365
+ textWidth: number;
366
+ margin: number;
367
+ deviceGap: number;
368
+ /** Reserved badge row (0 when no screen of this size has a badge). */
369
+ badgeRow: number;
370
+ badgeFont: number;
371
+ /** The set font (text metrics: FONT_CHAR_WIDTH). */
372
+ font: string;
373
+ /** Reserved text-block height = the TALLEST block among screens of this size. */
374
+ textArea: number;
375
+ }
376
+
377
+ interface TextBlock {
378
+ headlineLines: number;
379
+ headlineHeight: number;
380
+ subLines: number;
129
381
  subHeight: number;
130
382
  gap: number;
131
383
  height: number;
132
384
  }
133
385
 
134
- function measureTextBlock(screen: ComposeScreenPlan, unit: number, textWidth: number): TextBlock {
135
- let headlineSize = unit * HEADLINE_SIZE;
136
- if (estimateLines(screen.headline, headlineSize, textWidth) > 2) headlineSize = unit * HEADLINE_SIZE_LONG;
137
- const headlineHeight =
138
- estimateLines(screen.headline, headlineSize, textWidth) * headlineSize * HEADLINE_LINE_HEIGHT;
139
-
386
+ function measureTextBlock(screen: ComposeScreenPlan, t: Omit<Typography, 'textArea'>): TextBlock {
387
+ const headlineLines = estimateLines(screen.headline, t.headlineSize, t.textWidth, t.font);
388
+ const headlineHeight = headlineLines * t.headlineSize * HEADLINE_LINE_HEIGHT;
140
389
  const hasSub = !!screen.subheadline?.trim();
141
- const subSize = headlineSize * SUBHEADLINE_RATIO;
142
- const subHeight = hasSub
143
- ? estimateLines(screen.subheadline!, subSize, textWidth) * subSize * SUBHEADLINE_LINE_HEIGHT
144
- : 0;
145
- const gap = hasSub ? headlineSize * TEXT_GAP : 0;
146
- return { headlineSize, headlineHeight, subSize, subHeight, gap, height: headlineHeight + gap + subHeight };
390
+ const subLines = hasSub ? estimateLines(screen.subheadline!, t.subSize, t.textWidth, t.font) : 0;
391
+ const subHeight = subLines * t.subSize * SUBHEADLINE_LINE_HEIGHT;
392
+ const gap = hasSub ? t.headlineSize * TEXT_GAP : 0;
393
+ return { headlineLines, headlineHeight, subLines, subHeight, gap, height: t.badgeRow + headlineHeight + gap + subHeight };
394
+ }
395
+
396
+ /** Everything decided per screen before layers are built. */
397
+ interface ResolvedScreen {
398
+ index: number;
399
+ plan: ComposeScreenPlan;
400
+ W: number;
401
+ H: number;
402
+ layout: ComposeLayout;
403
+ presentation: ComposePresentation;
404
+ tilt: number;
405
+ background: BackgroundJSON;
406
+ backgroundFromStyle: boolean;
407
+ typo: Typography;
408
+ block: TextBlock;
409
+ group: string;
410
+ }
411
+
412
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
413
+
414
+ function normalizeFocus(focus: FocusBand | undefined): FocusBand | undefined {
415
+ if (!focus || typeof focus.top !== 'number' || typeof focus.bottom !== 'number') return undefined;
416
+ const top = clamp01(Math.min(focus.top, focus.bottom));
417
+ const bottom = clamp01(Math.max(focus.top, focus.bottom));
418
+ return bottom > top ? { top, bottom } : undefined;
419
+ }
420
+
421
+ /** A background's colour stops (solid → a subtle two-stop ramp around it). */
422
+ function backgroundStops(bg: BackgroundJSON): ColorStop[] {
423
+ const stops = bg.gradient?.colorStops;
424
+ if (bg.type === 'gradient' && stops && stops.length > 0) return stops;
425
+ // v1 gradients list bare `colors` (evenly spaced), like the editor's migrateScreenLayersJSON.
426
+ const v1 = bg.gradient?.colors?.filter(isHexColor) ?? [];
427
+ if (bg.type === 'gradient' && v1.length > 0) return evenStops(v1.length === 1 ? [v1[0], v1[0]] : v1);
428
+ const c = isHexColor(bg.color) ? bg.color : '#1F2937';
429
+ return [
430
+ { offset: 0, color: lighten(c, 0.14) },
431
+ { offset: 1, color: darken(c, 0.14) }
432
+ ];
433
+ }
434
+
435
+ function evenStops(colors: string[]): ColorStop[] {
436
+ return colors.map((color, i) => ({ offset: colors.length > 1 ? i / (colors.length - 1) : 0, color }));
437
+ }
438
+
439
+ const linear = (colorStops: ColorStop[]): BackgroundJSON => ({ type: 'gradient', gradient: { type: 'linear', colorStops } });
440
+
441
+ /** Background from the plan-level palette (null when there is none / it's unusable). */
442
+ function paletteBackground(palette: ComposePalette | undefined, index: number): BackgroundJSON | null {
443
+ const colors = (palette?.colors ?? []).filter(isHexColor);
444
+ if (!palette || colors.length === 0) return null;
445
+ if (palette.mode === 'sequence') {
446
+ const c = colors[index % colors.length];
447
+ return linear([
448
+ { offset: 0, color: lighten(c, 0.16) },
449
+ { offset: 1, color: c }
450
+ ]);
451
+ }
452
+ return linear(evenStops(colors.length === 1 ? [colors[0], darken(colors[0], 0.22)] : colors));
453
+ }
454
+
455
+ /** Colour of a ramp of `stops` at parameter t ∈ [0, 1] (non-hex stops are ignored). */
456
+ function colorAt(stops: ColorStop[], t: number): string {
457
+ const sorted = stops.filter((c) => isHexColor(c.color)).sort((a, b) => a.offset - b.offset);
458
+ if (sorted.length === 0) return '#1F2937';
459
+ if (t <= sorted[0].offset) return sorted[0].color;
460
+ for (let i = 1; i < sorted.length; i++) {
461
+ const a = sorted[i - 1];
462
+ const b = sorted[i];
463
+ if (t <= b.offset) return mixHex(a.color, b.color, b.offset > a.offset ? (t - a.offset) / (b.offset - a.offset) : 1);
464
+ }
465
+ return sorted[sorted.length - 1].color;
466
+ }
467
+
468
+ type Coords = { x1: number; y1: number; x2: number; y2: number };
469
+
470
+ /** Ramp parameter (0–1, padded at the ends) of point (x, y) on a linear gradient from p1 to p2. */
471
+ function rampT(c: Coords, x: number, y: number): number {
472
+ const dx = c.x2 - c.x1;
473
+ const dy = c.y2 - c.y1;
474
+ const len2 = dx * dx + dy * dy || 1;
475
+ return clamp01(((x - c.x1) * dx + (y - c.y1) * dy) / len2);
476
+ }
477
+
478
+ /** Where the editor draws an angle-based gradient over a w×h box (mirrors `deserializeBackground`). */
479
+ function angleCoords(angle: number | undefined, w: number, h: number): Coords {
480
+ const rad = (((angle || 180) - 90) * Math.PI) / 180;
481
+ return {
482
+ x1: (0.5 + Math.cos(rad) * 0.5) * w,
483
+ y1: (0.5 + Math.sin(rad) * 0.5) * h,
484
+ x2: (0.5 - Math.cos(rad) * 0.5) * w,
485
+ y2: (0.5 - Math.sin(rad) * 0.5) * h
486
+ };
147
487
  }
148
488
 
149
489
  /**
150
- * Deterministically assemble a Template from a plan. Each screen gets a background, a large
151
- * device frame carrying its screenshot and a bold headline (+ optional subheadline), arranged per the screen's
152
- * `layout`. All geometry is derived from the canvas size. Text layers are marked editable so the
153
- * user can tweak them in the editor. Claude decides the plan (benefit, copy, device, palette,
154
- * layout); this turns it into valid DSL.
490
+ * Colour of a LINEAR screen background at canvas point (x, y), placed exactly as the editor draws it
491
+ * (mirrors `deserializeBackground`: explicit coords, else `angle` — default 180°, i.e. offset 0 at
492
+ * the BOTTOM edge). Radial gradients are not modelled (they are sampled as if linear): auto-contrast
493
+ * only ever samples palette backgrounds, which are always linear, and panorama spans, which reject
494
+ * radial backgrounds.
155
495
  */
156
- export function composeTemplate(plan: ComposePlan): Template {
157
- const screens = plan.screens.map((screen) => {
158
- // Per-screen canvas dims: honor explicit plan-level dims (backward compatible — same as the
159
- // old single-size behavior), else derive from the screen's device class so a mixed-device
160
- // plan gets the correct aspect per screen.
161
- const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
162
- const { width: W, height: H } = explicit
163
- ? { width: plan.canvasWidth ?? 280, height: plan.canvasHeight ?? 600 }
164
- : canvasDimsForDevice(screen.deviceId);
496
+ export function sampleBackground(bg: BackgroundJSON, W: number, H: number, x: number, y: number): string {
497
+ if (bg.type !== 'gradient' || !bg.gradient) return isHexColor(bg.color) ? bg.color : '#1F2937';
498
+ return colorAt(backgroundStops(bg), rampT(bg.gradient.coords ?? angleCoords(bg.gradient.angle, W, H), x, y));
499
+ }
165
500
 
166
- const layout: ComposeLayout = screen.layout ?? 'text-top';
167
- const spec = LAYOUTS[layout] ?? LAYOUTS['text-top'];
168
- const unit = Math.min(W, H * TYPE_UNIT_HEIGHT_CAP);
169
- const textWidth = W * TEXT_WIDTH;
170
- const margin = H * EDGE_MARGIN;
171
- const deviceGap = unit * DEVICE_GAP;
172
- const block = measureTextBlock(screen, unit, textWidth);
173
-
174
- // Text block vertical extent.
175
- const blockTop = layout === 'text-bottom' ? H - margin - block.height : margin;
176
-
177
- // Device size: target a fraction of the canvas width, but never let more than `maxBleed` of
178
- // the device fall off-canvas (keeps squat tablet/laptop canvases sane).
179
- const device = getDeviceFrame(screen.deviceId);
180
- if (!device) throw new Error(`Unknown device: ${screen.deviceId}`);
181
- const { width: fw, height: fh } = device.imageDimensions;
182
- let scale: number;
183
- let centerY: number;
184
- if (layout === 'text-bottom') {
185
- const deviceBottom = blockTop - deviceGap;
186
- scale = Math.min((W * spec.deviceWidth) / fw, deviceBottom / (1 - spec.maxBleed) / fh);
187
- centerY = deviceBottom - (fh * scale) / 2; // top edge bleeds off the top when tall
188
- } else {
189
- const deviceTop = Math.max(margin + block.height + deviceGap, H * spec.minDeviceTop);
190
- scale = Math.min((W * spec.deviceWidth) / fw, (H - deviceTop) / (1 - spec.maxBleed) / fh);
191
- centerY = deviceTop + (fh * scale) / 2; // bottom edge bleeds off the bottom when tall
501
+ /** What a panorama span paints across its N·W × H rect. */
502
+ type SpanFill = { kind: 'solid'; color: string } | { kind: 'linear'; stops: ColorStop[]; coords: Coords };
503
+
504
+ /**
505
+ * The span-start screen's background, stretched across the whole span, keeping its colours, stop
506
+ * offsets and `angle` (an angle-less gradient runs diagonally corner to corner; a solid colour stays
507
+ * solid). Rejects what can't run across screens: radial gradients and explicit per-screen `coords`.
508
+ */
509
+ function spanFillFor(bg: BackgroundJSON, first: number, N: number, W: number, H: number): SpanFill {
510
+ if (bg.type !== 'gradient' || !bg.gradient) return { kind: 'solid', color: isHexColor(bg.color) ? bg.color : '#1F2937' };
511
+ const at = `panorama span starting at screen ${first + 1}`;
512
+ if (bg.gradient.type === 'radial') {
513
+ throw new Error(`${at}: a radial gradient background can't run across screens — use a linear gradient or a solid colour`);
514
+ }
515
+ if (bg.gradient.coords) {
516
+ throw new Error(`${at}: explicit gradient \`coords\` are per-screen and can't span screens — use \`angle\` (or omit it) instead`);
517
+ }
518
+ const stops = backgroundStops(bg);
519
+ const coords = bg.gradient.angle != null ? angleCoords(bg.gradient.angle, N * W, H) : { x1: 0, y1: 0, x2: N * W, y2: H };
520
+ return { kind: 'linear', stops, coords };
521
+ }
522
+
523
+ /** Colour of a span fill at point (x, y) of span screen k. */
524
+ function samplePanorama(fill: SpanFill, k: number, W: number, x: number, y: number): string {
525
+ return fill.kind === 'solid' ? fill.color : colorAt(fill.stops, rampT(fill.coords, k * W + x, y));
526
+ }
527
+
528
+ /** The subject (what is placed under the text) of a framed / frameless screen. */
529
+ function subjectFor(r: ResolvedScreen): Subject {
530
+ if (r.presentation === 'device') {
531
+ const device = getDeviceFrame(r.plan.deviceId);
532
+ if (!device) throw new Error(`Unknown device: ${r.plan.deviceId}`);
533
+ const { width, height } = device.imageDimensions;
534
+ return { width, height, screen: { ...device.screenBounds } };
535
+ }
536
+ const { width, height } = r.plan.screenshot;
537
+ return { width, height, screen: { x: 0, y: 0, width, height } };
538
+ }
539
+
540
+ interface Placement {
541
+ cx: number;
542
+ cy: number;
543
+ scale: number;
544
+ angle: number;
545
+ /** Rendered rotated bounding box. */
546
+ boxWidth: number;
547
+ boxHeight: number;
548
+ vertical: VerticalResult;
549
+ subject: Subject;
550
+ /** Straddlers only: validated rightward shift that makes the device cross the seam. */
551
+ straddleDx?: number;
552
+ /** zoom only */
553
+ zoom?: { cropX: number; cropY: number; cropW: number; cropH: number; focusCropped: boolean };
554
+ }
555
+
556
+ /** Near-edge start for a screen's subject: below (or above, for text-bottom) the reserved text area. */
557
+ function nearStart(t: Typography): number {
558
+ return t.margin + t.textArea + t.deviceGap;
559
+ }
560
+
561
+ /**
562
+ * Fit a zoom crop (natural px) to the card aspect `A` = height / width, centred on the requested
563
+ * region. An explicit `crop` keeps its full width (the model chose it); a focus-derived band is
564
+ * zoomed into its centre, up to ZOOM_MAX_MAG× the full-width fit. The height always covers the
565
+ * requested band when the image allows (else `focusCropped`).
566
+ */
567
+ function fitCrop(
568
+ shot: { width: number; height: number },
569
+ req: { x: number; y: number; w: number; h: number },
570
+ A: number,
571
+ explicit: boolean
572
+ ): { cropX: number; cropY: number; cropW: number; cropH: number; focusCropped: boolean } {
573
+ const minW = explicit ? req.w : shot.width / ZOOM_MAX_MAG;
574
+ let w = Math.min(shot.width, Math.max(minW, req.h / A));
575
+ let h = w * A;
576
+ if (h > shot.height) {
577
+ h = shot.height;
578
+ w = h / A;
579
+ }
580
+ const focusCropped = h < req.h - 0.5 || (explicit && w < req.w - 0.5);
581
+ const cx = req.x + req.w / 2;
582
+ const cy = req.y + req.h / 2;
583
+ const cropX = Math.min(Math.max(0, cx - w / 2), shot.width - w);
584
+ const cropY = Math.min(Math.max(0, cy - h / 2), shot.height - h);
585
+ return { cropX, cropY, cropW: w, cropH: h, focusCropped };
586
+ }
587
+
588
+ const HORIZONTAL_EPS = 1e-9;
589
+ /** A tilted device may shrink to this × its straight scale to keep its margins; else the tilt is reduced. */
590
+ const TILT_MIN_SCALE = 0.8;
591
+
592
+ /**
593
+ * The horizontal no-tangent + focus rules for one pose: the subject's VISIBLE extent keeps
594
+ * `sideMargin`·W from both side edges (the right edge is waived for a deliberate straddle), and
595
+ * every focus band's rotated corners stay `focusSideSafe`·W inside the edges.
596
+ */
597
+ function horizontalOk(
598
+ subject: Subject,
599
+ pose: { cx: number; cy: number; scale: number; angle: number },
600
+ W: number,
601
+ H: number,
602
+ focuses: FocusBand[],
603
+ straddleRight = false
604
+ ): boolean {
605
+ const eps = HORIZONTAL_EPS * W;
606
+ const ext = visibleXExtent(subject, pose, H);
607
+ if (ext) {
608
+ const m = NO_TANGENT.sideMargin * W;
609
+ if (ext.min < m - eps) return false;
610
+ if (!straddleRight && ext.max > W - m + eps) return false;
611
+ }
612
+ const fs = NO_TANGENT.focusSideSafe * W;
613
+ return focuses.every((f) => {
614
+ const fx = focusXExtent(subject, f, pose);
615
+ return fx.min >= fs - eps && fx.max <= W - fs + eps;
616
+ });
617
+ }
618
+
619
+ /**
620
+ * Rightward shift for a panorama straddle: the visible device crosses the seam by STRADDLE_OVERLAP
621
+ * of its visible width, limited so every focus band stays `focusSideSafe`·W inside the seam. Null
622
+ * when that leaves less than a decisive STRADDLE_MIN crossing (or would move the device left).
623
+ */
624
+ function straddleShift(
625
+ subject: Subject,
626
+ pose: { cx: number; cy: number; scale: number; angle: number },
627
+ W: number,
628
+ H: number,
629
+ focuses: FocusBand[]
630
+ ): number | null {
631
+ const ext = visibleXExtent(subject, pose, H);
632
+ if (!ext) return null;
633
+ const visW = ext.max - ext.min;
634
+ let dx = W + STRADDLE_OVERLAP * visW - ext.max;
635
+ for (const f of focuses) dx = Math.min(dx, W * (1 - NO_TANGENT.focusSideSafe) - focusXExtent(subject, f, pose).max);
636
+ const overlap = ext.max + dx - W;
637
+ return dx > 0 && overlap >= STRADDLE_MIN * visW ? dx : null;
638
+ }
639
+
640
+ const screensOf = (members: ResolvedScreen[]) => members.map((m) => m.index + 1).join(', ');
641
+
642
+ /** The copy is the cause: the device gets < `minScale` of what it would get with no copy at all. */
643
+ const copyTooLong = (members: ResolvedScreen[], W: number, H: number) =>
644
+ new Error(
645
+ `copy too long for this canvas (${W}×${H}, screen ${screensOf(members)}): the text leaves the device ` +
646
+ `less than ${Math.round(NO_TANGENT.minScale * 100)}% of the room it gets with no copy — cut the subheadline or headline`
647
+ );
648
+
649
+ /** The canvas is the cause: even with no copy the subject can't reach a usable size. */
650
+ const canvasMismatch = (members: ResolvedScreen[], W: number, H: number, what: string, pct: number) =>
651
+ new Error(
652
+ `${what} doesn't fit a ${W}×${H} canvas (screen ${screensOf(members)}): even with no copy it renders at only ` +
653
+ `${Math.round(pct * 100)}% of its target width — use the device's own canvas (omit canvasWidth/canvasHeight) or another device`
654
+ );
655
+
656
+ /** Below this × its target width even with NO copy, the canvas (not the copy) is the problem. */
657
+ const MIN_EMPTY_COPY_FIT = 0.3;
658
+
659
+ /**
660
+ * Pose of every screen in a group (they share scale, baseline and far-edge treatment).
661
+ * Vertical: `solveVertical` (no tangent, focus visible). Horizontal: the pose must pass
662
+ * `horizontalOk` for every member; if the tilt makes it too wide, the scale is capped (by at most
663
+ * TILT_MIN_SCALE), then the tilt is reduced in quarter steps down to 0 (a straight device ≤ 90% W
664
+ * always fits), with a `tilt-reduced` warning. Zoom cards follow the same ladder, keeping their
665
+ * aspect. Throws `copyTooLong` when the copy leaves < minScale of the no-copy fit, and
666
+ * `canvasMismatch` when even the no-copy fit is unusable (N1).
667
+ */
668
+ function placeGroup(
669
+ members: ResolvedScreen[],
670
+ bleed: BleedPreference,
671
+ warnings: ComposeWarning[],
672
+ /** Every member is a requested panorama straddler: the right side margin is waived (it crosses the seam). */
673
+ straddleRight = false
674
+ ): Map<number, Placement> {
675
+ const out = new Map<number, Placement>();
676
+ const first = members[0];
677
+ const { W, H, typo, layout, presentation, tilt } = first;
678
+ const spec = LAYOUTS[layout] ?? LAYOUTS['text-top'];
679
+ const farEdge = layout === 'text-bottom' ? 'top' : 'bottom';
680
+ const near = nearStart(typo);
681
+ const reduced = (to: number) => {
682
+ if (to === tilt) return;
683
+ warnings.push({
684
+ screen: first.index,
685
+ code: 'tilt-reduced',
686
+ message: `screen ${members.map((m) => m.index + 1).join(', ')}: tilt reduced from ${tilt}° to ${Math.round(to * 10) / 10}° so the device and its focus band keep their side margins`
687
+ });
688
+ };
689
+
690
+ if (presentation === 'zoom') {
691
+ // One card box for the group. Straight, it spans the full remaining height (clear of the far
692
+ // edge by the margin) at ZOOM_WIDTH·W, never flatter than ZOOM_MIN_ASPECT. That ASPECT is kept at
693
+ // every tilt: a tilted card scales down (≤ TILT_MIN_SCALE) until its rotated bounding box fits
694
+ // the remaining height and the 90%-W side margins; beyond that the tilt is reduced (quarter
695
+ // steps, `tilt-reduced`), and a straight card always fits.
696
+ const avail = H * (1 - NO_TANGENT.clearGap) - near;
697
+ const availEmpty = H * (1 - NO_TANGENT.clearGap) - (typo.margin + typo.deviceGap);
698
+ if (!(avail > 0) || avail < NO_TANGENT.minScale * availEmpty) throw copyTooLong(members, W, H);
699
+ let baseW = ZOOM_WIDTH * W;
700
+ const baseH = avail;
701
+ if (baseH < baseW * ZOOM_MIN_ASPECT) {
702
+ baseW = baseH / ZOOM_MIN_ASPECT;
703
+ }
704
+ const aspect = baseH / baseW;
705
+ const maxBoxWidth = (1 - 2 * NO_TANGENT.sideMargin) * W;
706
+ const fitAt = (angle: number) => {
707
+ const rad = (angle * Math.PI) / 180;
708
+ const sin = Math.abs(Math.sin(rad));
709
+ const cos = Math.abs(Math.cos(rad));
710
+ const k = Math.min(1, maxBoxWidth / (baseW * (cos + aspect * sin)), avail / (baseW * (sin + aspect * cos)));
711
+ return { k, boxW: k * baseW, boxH: k * baseW * aspect };
712
+ };
713
+ let angleUsed = 0;
714
+ let fit = fitAt(0);
715
+ for (const angle of tilt === 0 ? [0] : [tilt, tilt * 0.75, tilt * 0.5, tilt * 0.25, 0]) {
716
+ const f = fitAt(angle);
717
+ if (angle === 0 || f.k >= TILT_MIN_SCALE - 1e-9) {
718
+ angleUsed = angle;
719
+ fit = f;
720
+ break;
721
+ }
722
+ }
723
+ reduced(angleUsed);
724
+ const tiltUsed = angleUsed;
725
+ const { boxW, boxH } = fit;
726
+ const box = rotatedBox(boxW, boxH, tiltUsed);
727
+ for (const r of members) {
728
+ const shot = r.plan.screenshot;
729
+ const focus = normalizeFocus(r.plan.focus);
730
+ const crop = r.plan.crop;
731
+ const req = crop
732
+ ? { x: clamp01(crop.x) * shot.width, y: clamp01(crop.y) * shot.height, w: Math.max(1, clamp01(crop.w) * shot.width), h: Math.max(1, clamp01(crop.h) * shot.height) }
733
+ : focus
734
+ ? { x: 0, y: focus.top * shot.height, w: shot.width, h: (focus.bottom - focus.top) * shot.height }
735
+ : { x: 0, y: 0, w: shot.width, h: Math.min(shot.height, shot.width * (boxH / boxW)) };
736
+ const zoom = fitCrop(shot, req, boxH / boxW, !!crop);
737
+ const scale = boxW / zoom.cropW;
738
+ const nearEdge = near;
739
+ const cy = farEdge === 'bottom' ? nearEdge + box.height / 2 : H - nearEdge - box.height / 2;
740
+ const overshoot = nearEdge + box.height - H;
741
+ out.set(r.index, {
742
+ cx: W / 2,
743
+ cy,
744
+ scale,
745
+ angle: tiltUsed,
746
+ boxWidth: box.width,
747
+ boxHeight: box.height,
748
+ vertical: { scale, near: nearEdge, overshoot, mode: 'clear', bleedFraction: overshoot / box.height, reason: 'zoom card clears the edge' },
749
+ subject: { width: zoom.cropW, height: zoom.cropH, screen: { x: 0, y: 0, width: zoom.cropW, height: zoom.cropH } },
750
+ zoom
751
+ });
192
752
  }
753
+ return out;
754
+ }
193
755
 
194
- // One device layer; the screenshot rides on it as `fabricData.screenshot` and the editor
195
- // places + clips it under the frame on import (see DeviceScreenshotJSON).
196
- const frame = makeDeviceFrameLayer({
197
- deviceId: screen.deviceId,
198
- screenshotUrl: screen.screenshot.url,
199
- screenshotWidth: screen.screenshot.width,
200
- screenshotHeight: screen.screenshot.height,
756
+ const subject = subjectFor(first);
757
+ const targetWidth = Math.min(spec.deviceWidth * (presentation === 'frameless' ? FRAMELESS_WIDTH : 1), NO_TANGENT.maxWidth);
758
+ const targetScale = (targetWidth * W) / subject.width;
759
+ // Every member's focus band is honoured by the shared pose (the most restrictive one decides).
760
+ const focuses = members.map((r) => normalizeFocus(r.plan.focus)).filter((f): f is FocusBand => !!f);
761
+ const solve = (angle: number, scaleCap?: number, nearOverride?: number) => {
762
+ const box = rotatedBox(subject.width, subject.height, angle);
763
+ const reaches = focuses.map((f) => focusReach(subject, f, angle, farEdge));
764
+ const vertical = solveVertical({
201
765
  canvasWidth: W,
202
766
  canvasHeight: H,
203
- centerX: W / 2,
204
- centerY,
205
- scale
767
+ near: nearOverride ?? near,
768
+ minNear: spec.minDeviceTop * H,
769
+ baseWidth: subject.width,
770
+ boxHeight: box.height,
771
+ targetWidth,
772
+ maxBleed: spec.maxBleed,
773
+ focusReach: reaches.length ? Math.max(...reaches) : null,
774
+ bleed,
775
+ preferBleed: angle !== 0,
776
+ scaleCap
206
777
  });
778
+ const s = vertical.scale;
779
+ const cy = farEdge === 'bottom' ? vertical.near + (s * box.height) / 2 : H - vertical.near - (s * box.height) / 2;
780
+ return { vertical, box, cy, angle };
781
+ };
207
782
 
208
- // Center origin (editor convention): left/top are the box CENTER.
209
- const headlineColor = screen.headlineColor ?? '#ffffff';
210
- const headline = makeTextLayer({
211
- text: screen.headline,
212
- left: W / 2,
213
- top: blockTop + block.headlineHeight / 2,
214
- width: textWidth,
215
- fontSize: block.headlineSize,
216
- fontWeight: '800',
217
- lineHeight: HEADLINE_LINE_HEIGHT,
218
- fill: headlineColor,
219
- textAlign: 'center',
220
- name: 'Headline',
221
- templateRole: 'editable',
222
- templateKey: 'headline'
783
+ // L7 / N1: the room gate is relative to what the SAME subject gets on this canvas with NO copy
784
+ // (height-limited on landscape canvases), so a tall device on a squat canvas isn't blamed on the
785
+ // copy. Too small even with no copy ⇒ the canvas/device aspect is the problem.
786
+ const straight = solve(0);
787
+ const empty = solve(0, undefined, typo.margin + typo.deviceGap).vertical.scale;
788
+ if (!(empty >= MIN_EMPTY_COPY_FIT * targetScale - 1e-9)) {
789
+ throw canvasMismatch(members, W, H, presentation === 'device' ? `device ${first.plan.deviceId}` : 'this screenshot', empty / targetScale);
790
+ }
791
+ const minScale = NO_TANGENT.minScale * empty;
792
+ if (!(straight.vertical.scale >= minScale - 1e-9)) throw copyTooLong(members, W, H);
793
+
794
+ // A tilt may cost at most TILT_MIN_SCALE of the straight size; beyond that the tilt is reduced
795
+ // (in quarter steps down to 0) rather than shrinking the device further.
796
+ const tiltFloor = Math.max(minScale, TILT_MIN_SCALE * straight.vertical.scale);
797
+ // Search: tilt candidates (reduced in quarter steps), each with a shrinking scale cap. A straddler
798
+ // is judged AFTER its seam shift (right margin waived; left margin + focus vs seam checked); if no
799
+ // straddling pose exists it is placed as a normal screen (the caller then skips the straddle).
800
+ const search = (asStraddle: boolean) => {
801
+ for (const angle of tilt === 0 ? [0] : [tilt, tilt * 0.75, tilt * 0.5, tilt * 0.25, 0]) {
802
+ const floor = angle === 0 ? minScale : tiltFloor;
803
+ let cap: number | undefined;
804
+ for (let i = 0; i < 400; i++) {
805
+ const sol = solve(angle, cap);
806
+ const pose = { cx: W / 2, cy: sol.cy, scale: sol.vertical.scale, angle };
807
+ if (sol.vertical.scale >= floor - 1e-9) {
808
+ if (!asStraddle && horizontalOk(subject, pose, W, H, focuses)) return { sol, dx: undefined as number | undefined };
809
+ if (asStraddle) {
810
+ const dx = straddleShift(subject, pose, W, H, focuses);
811
+ if (dx != null && horizontalOk(subject, { ...pose, cx: pose.cx + dx }, W, H, focuses, true)) return { sol, dx };
812
+ }
813
+ }
814
+ const next = sol.vertical.scale * 0.985;
815
+ if (next < floor) break;
816
+ cap = next;
817
+ }
818
+ }
819
+ return null;
820
+ };
821
+ // A straight, centred device at ≤ 90% W always passes; `straight` is the defensive fallback.
822
+ const found = (straddleRight ? search(true) : null) ?? search(false) ?? { sol: straight, dx: undefined };
823
+ const chosen = found.sol;
824
+ reduced(chosen.angle);
825
+ const s = chosen.vertical.scale;
826
+ for (const r of members) {
827
+ out.set(r.index, {
828
+ cx: W / 2,
829
+ cy: chosen.cy,
830
+ scale: s,
831
+ angle: chosen.angle,
832
+ boxWidth: s * chosen.box.width,
833
+ boxHeight: s * chosen.box.height,
834
+ vertical: chosen.vertical,
835
+ subject,
836
+ straddleDx: found.dx
223
837
  });
838
+ }
839
+ return out;
840
+ }
841
+
842
+ function shadowFor(W: number, scale: number) {
843
+ // Fabric scales shadow blur/offset by the object's scale (the editor's shadow controls use the
844
+ // same convention), so express them in the image's own units.
845
+ return { color: SHADOW.color, blur: (SHADOW.blur * W) / scale, offsetX: 0, offsetY: (SHADOW.offsetY * W) / scale };
846
+ }
847
+
848
+ /** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
849
+ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement): LayerJSON {
850
+ const id = generateLayerId();
851
+ const shot = r.plan.screenshot;
852
+ const zoom = p.zoom;
853
+ const width = zoom ? zoom.cropW : shot.width;
854
+ const height = zoom ? zoom.cropH : shot.height;
855
+ const radius = zoom ? ZOOM_RADIUS * r.W : FRAMELESS_RADIUS * width * p.scale;
856
+ const fabricData: Record<string, unknown> = {
857
+ type: 'image',
858
+ src: shot.url,
859
+ crossOrigin: 'anonymous',
860
+ left: p.cx,
861
+ top: p.cy,
862
+ width,
863
+ height,
864
+ scaleX: p.scale,
865
+ scaleY: p.scale,
866
+ originX: 'center',
867
+ originY: 'center',
868
+ // Rounded corners in the image's local (unscaled) space — same shape the editor's corner
869
+ // radius control writes (`imageCornerRadius` is in canvas units).
870
+ clipPath: {
871
+ type: 'Rect',
872
+ left: 0,
873
+ top: 0,
874
+ width,
875
+ height,
876
+ rx: radius / p.scale,
877
+ ry: radius / p.scale,
878
+ originX: 'center',
879
+ originY: 'center'
880
+ },
881
+ imageCornerRadius: radius,
882
+ shadow: shadowFor(r.W, p.scale),
883
+ layerId: id,
884
+ layerType: 'image'
885
+ };
886
+ if (zoom) {
887
+ fabricData.cropX = zoom.cropX;
888
+ fabricData.cropY = zoom.cropY;
889
+ }
890
+ if (p.angle) fabricData.angle = p.angle;
891
+ return {
892
+ id,
893
+ name: zoom ? 'Screenshot (zoom)' : 'Screenshot',
894
+ type: 'image',
895
+ visible: true,
896
+ locked: false,
897
+ fabricData
898
+ };
899
+ }
224
900
 
225
- // BOTTOM → TOP (canvas add order): device -> headline -> subheadline.
226
- const layers: LayerJSON[] = [frame, headline];
901
+ function makeSubjectLayer(r: ResolvedScreen, p: Placement, cx = p.cx, name?: string): LayerJSON {
902
+ if (r.presentation === 'device') {
903
+ // One device layer; the screenshot rides on it as `fabricData.screenshot` and the editor
904
+ // places + clips it under the frame on import (see DeviceScreenshotJSON).
905
+ return makeDeviceFrameLayer({
906
+ deviceId: r.plan.deviceId,
907
+ screenshotUrl: r.plan.screenshot.url,
908
+ screenshotWidth: r.plan.screenshot.width,
909
+ screenshotHeight: r.plan.screenshot.height,
910
+ canvasWidth: r.W,
911
+ canvasHeight: r.H,
912
+ centerX: cx,
913
+ centerY: p.cy,
914
+ scale: p.scale,
915
+ angle: p.angle,
916
+ name
917
+ });
918
+ }
919
+ const layer = makeScreenshotImageLayer(r, p);
920
+ (layer.fabricData as Record<string, unknown>).left = cx;
921
+ if (name) layer.name = name;
922
+ return layer;
923
+ }
924
+
925
+ /** Canvas corners of a screen's focus band for a given pose (null when no focus is marked). */
926
+ function focusPolygon(r: ResolvedScreen, p: Placement, cx: number) {
927
+ const focus = normalizeFocus(r.plan.focus);
928
+ if (!focus || p.zoom) return null;
929
+ return focusCorners(p.subject, focus).map((pt) => subjectPointToCanvas(p.subject, pt, { cx, cy: p.cy, scale: p.scale, angle: p.angle }));
930
+ }
931
+
932
+ interface TextRects {
933
+ badge?: { left: number; top: number; right: number; bottom: number };
934
+ headline: { left: number; top: number; right: number; bottom: number };
935
+ sub?: { left: number; top: number; right: number; bottom: number };
936
+ }
937
+
938
+ function validateSpans(plan: ComposePlan, dims: Array<{ W: number; H: number }>): number[][] {
939
+ const spans = plan.style?.panorama?.spans ?? [];
940
+ const seen = new Set<number>();
941
+ for (const span of spans) {
942
+ if (!Array.isArray(span) || span.length < 2) throw new Error('panorama span must list at least 2 screens');
943
+ span.forEach((idx, k) => {
944
+ if (!Number.isInteger(idx) || idx < 0 || idx >= plan.screens.length) throw new Error(`panorama span index ${idx} is out of range`);
945
+ if (k > 0 && idx !== span[k - 1] + 1) throw new Error('panorama span screens must be adjacent and ascending');
946
+ if (seen.has(idx)) throw new Error(`screen ${idx} is in more than one panorama span`);
947
+ seen.add(idx);
948
+ if (dims[idx].W !== dims[span[0]].W || dims[idx].H !== dims[span[0]].H) {
949
+ throw new Error('panorama span screens must share one canvas size');
950
+ }
951
+ });
952
+ }
953
+ const straddle = plan.style?.panorama?.straddle;
954
+ if (Array.isArray(straddle)) {
955
+ for (const idx of straddle) {
956
+ if (!spans.some((span) => span[0] === idx)) throw new Error(`panorama straddle ${idx} is not the first screen of a span`);
957
+ }
958
+ }
959
+ return spans;
960
+ }
961
+
962
+ /**
963
+ * Two-pass SET layout: measure every text block → one type size + a text area reserved for the
964
+ * tallest block (per canvas size) → one subject scale/baseline per group (canvas, presentation,
965
+ * subject, layout, tilt) → the no-tangent bleed rule (respecting `focus`) → per-screen layers.
966
+ * Copy length never moves or resizes anything; copy-rule violations come back as `report.warnings`.
967
+ */
968
+ export function composeSet(plan: ComposePlan): { template: Template; report: ComposeReport } {
969
+ const style = plan.style ?? {};
970
+ const warnings: ComposeWarning[] = [];
971
+ const bleedPref: BleedPreference = (COMPOSE_BLEEDS as readonly string[]).includes(style.bleed ?? '') ? style.bleed! : 'auto';
972
+ const font = style.font && COMPOSE_FONTS.includes(style.font) ? style.font : 'Inter';
973
+ const tiltScreens = new Set(style.tiltScreens ?? []);
974
+
975
+ // Per-screen canvas dims: honor explicit plan-level dims (backward compatible), else derive from
976
+ // the screen's device class so a mixed-device plan gets the correct aspect per screen.
977
+ const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
978
+ const dims = plan.screens.map((screen) => {
979
+ if (!getDeviceFrame(screen.deviceId)) throw new Error(`Unknown device: ${screen.deviceId}`);
980
+ const { width, height } = explicit
981
+ ? { width: plan.canvasWidth ?? 280, height: plan.canvasHeight ?? 600 }
982
+ : canvasDimsForDevice(screen.deviceId);
983
+ return { W: width, H: height };
984
+ });
985
+ const spans = validateSpans(plan, dims);
986
+ const spanOf = new Map<number, { span: number[]; k: number }>();
987
+ for (const span of spans) span.forEach((idx, k) => spanOf.set(idx, { span, k }));
988
+
989
+ // Pass 1: set typography per canvas size (tallest text block wins).
990
+ const typoByDims = new Map<string, Typography>();
991
+ const dimsKey = (d: { W: number; H: number }) => `${d.W}x${d.H}`;
992
+ plan.screens.forEach((_, i) => {
993
+ const key = dimsKey(dims[i]);
994
+ if (typoByDims.has(key)) return;
995
+ const { W, H } = dims[i];
996
+ const unit = Math.min(W, H * TYPE_UNIT_HEIGHT_CAP);
997
+ const headlineSize = unit * HEADLINE_SIZE;
998
+ const hasBadge = plan.screens.some((s, j) => dimsKey(dims[j]) === key && !!s.badge?.trim());
999
+ const badgeFont = headlineSize * BADGE_FONT;
1000
+ typoByDims.set(key, {
1001
+ W,
1002
+ H,
1003
+ unit,
1004
+ headlineSize,
1005
+ subSize: headlineSize * SUBHEADLINE_RATIO,
1006
+ textWidth: W * TEXT_WIDTH,
1007
+ margin: H * EDGE_MARGIN,
1008
+ deviceGap: unit * DEVICE_GAP,
1009
+ badgeFont,
1010
+ font,
1011
+ badgeRow: hasBadge ? badgeFont * BADGE_HEIGHT + headlineSize * BADGE_GAP : 0,
1012
+ textArea: 0
1013
+ });
1014
+ });
1015
+
1016
+ const resolved: ResolvedScreen[] = plan.screens.map((screen, i) => {
1017
+ const typo = typoByDims.get(dimsKey(dims[i]))!;
1018
+ const block = measureTextBlock(screen, typo);
1019
+ typo.textArea = Math.max(typo.textArea, block.height);
1020
+ const presentation: ComposePresentation = (COMPOSE_PRESENTATIONS as readonly string[]).includes(screen.presentation ?? '')
1021
+ ? screen.presentation!
1022
+ : (COMPOSE_PRESENTATIONS as readonly string[]).includes(style.presentation ?? '')
1023
+ ? style.presentation!
1024
+ : 'device';
1025
+ let layout: ComposeLayout = screen.layout && LAYOUTS[screen.layout] ? screen.layout : 'text-top';
1026
+ if (presentation === 'zoom' && layout === 'device-bleed') layout = 'text-top';
1027
+ const rawTilt = typeof screen.tilt === 'number' ? screen.tilt : tiltScreens.has(i) ? (style.tilt ?? 0) : 0;
1028
+ const tilt = Number.isFinite(rawTilt) ? Math.max(-30, Math.min(30, rawTilt)) : 0;
1029
+ const fromPalette = screen.background ? null : paletteBackground(style.palette, i);
1030
+ const background = screen.background ?? fromPalette ?? { type: 'solid', color: '#1F2937' };
1031
+ const backgroundFromStyle = !screen.background;
1032
+ const subjectKey =
1033
+ presentation === 'device'
1034
+ ? screen.deviceId
1035
+ : presentation === 'frameless'
1036
+ ? `shot:${screen.screenshot.width}x${screen.screenshot.height}`
1037
+ : 'card';
1038
+ const group = `${dims[i].W}x${dims[i].H}|${presentation}|${subjectKey}|${layout}|tilt:${tilt}`;
1039
+ return {
1040
+ index: i,
1041
+ plan: screen,
1042
+ W: dims[i].W,
1043
+ H: dims[i].H,
1044
+ layout,
1045
+ presentation,
1046
+ tilt,
1047
+ background,
1048
+ backgroundFromStyle,
1049
+ typo,
1050
+ block,
1051
+ group
1052
+ };
1053
+ });
1054
+
1055
+ // Requested straddlers: the first screen of a span, per `panorama.straddle` (never zoom cards).
1056
+ // They get their own group (their pose is judged after the seam shift).
1057
+ const straddleSpec = style.panorama?.straddle;
1058
+ const wantsStraddle = new Set(
1059
+ straddleSpec
1060
+ ? spans.map((span) => span[0]).filter((i) => (Array.isArray(straddleSpec) ? straddleSpec.includes(i) : true) && resolved[i].presentation !== 'zoom')
1061
+ : []
1062
+ );
1063
+ for (const i of wantsStraddle) resolved[i].group += '|straddle';
1064
+
1065
+ // Pass 2: one pose per group.
1066
+ const groups = new Map<string, ResolvedScreen[]>();
1067
+ for (const r of resolved) groups.set(r.group, [...(groups.get(r.group) ?? []), r]);
1068
+ const placements = new Map<number, Placement>();
1069
+ for (const members of groups.values()) {
1070
+ const asStraddle = members.every((m) => wantsStraddle.has(m.index));
1071
+ for (const [i, p] of placeGroup(members, bleedPref, warnings, asStraddle)) placements.set(i, p);
1072
+ }
1073
+
1074
+ // Panorama: straddling devices (first screen of a span) cross into the next screen.
1075
+ const straddle = new Map<number, { cx: number; into: number }>();
1076
+ if (wantsStraddle.size) {
1077
+ for (const span of spans) {
1078
+ const i = span[0];
1079
+ if (!wantsStraddle.has(i)) continue;
1080
+ const r = resolved[i];
1081
+ const p = placements.get(i)!;
1082
+ if (r.presentation === 'zoom') continue;
1083
+ const W = r.W;
1084
+ // placeGroup found (or failed to find) a pose that straddles within the side / seam rules.
1085
+ const dx = p.straddleDx;
1086
+ const focus = normalizeFocus(r.plan.focus);
1087
+ if (dx == null) {
1088
+ warnings.push({
1089
+ screen: i,
1090
+ code: 'straddle-skipped',
1091
+ message: `screen ${i + 1}: device kept inside its screen — crossing the seam would put its focus band across it (or break the side margin)`
1092
+ });
1093
+ continue;
1094
+ }
1095
+ if (!focus) {
1096
+ warnings.push({
1097
+ screen: i,
1098
+ code: 'panorama-seam',
1099
+ message: `screen ${i + 1}: the device crosses the seam into screen ${i + 2} but has no \`focus\` band marked — mark one so the selling UI provably stays on screen ${i + 1}`
1100
+ });
1101
+ }
1102
+ straddle.set(i, { cx: W / 2 + dx, into: span[1] });
1103
+ }
1104
+ }
1105
+
1106
+ // Span fills (validated up front, so a bad span background fails before any layer is built).
1107
+ const spanFills = new Map<number, SpanFill>();
1108
+ for (const span of spans) {
1109
+ const r0 = resolved[span[0]];
1110
+ spanFills.set(span[0], spanFillFor(r0.background, span[0], span.length, r0.W, r0.H));
1111
+ }
1112
+ /** Vertical band of a screen's reserved text block (with a small breathing gap). */
1113
+ const textBand = (r: ResolvedScreen) => {
1114
+ const top = r.layout === 'text-bottom' ? r.H - r.typo.margin - r.typo.textArea : r.typo.margin;
1115
+ const gap = ORB_TEXT_GAP * r.H;
1116
+ return { top: top - gap, bottom: top + r.typo.textArea + gap };
1117
+ };
1118
+ /**
1119
+ * The orb on seam `seam` of a span: centred on ORB_Y·H with ORB_RADIUS·W, but moved/shrunk so it
1120
+ * never overlaps either neighbour's text block (small landscape canvases); null when there's no room.
1121
+ */
1122
+ const orbFor = (span: number[], seam: number): { cy: number; r: number } | null => {
1123
+ const a = resolved[span[seam - 1]];
1124
+ const b = resolved[span[seam]];
1125
+ const H = a.H;
1126
+ const blocks = [textBand(a), textBand(b)].sort((x, y) => x.top - y.top);
1127
+ const free: Array<{ lo: number; hi: number }> = [];
1128
+ let cursor = 0;
1129
+ for (const t of blocks) {
1130
+ if (t.top > cursor) free.push({ lo: cursor, hi: t.top });
1131
+ cursor = Math.max(cursor, t.bottom);
1132
+ }
1133
+ if (cursor < H) free.push({ lo: cursor, hi: H });
1134
+ const want = ORB_Y * H;
1135
+ const pick = free.find((f) => f.lo <= want && want <= f.hi) ?? free.sort((x, y) => y.hi - y.lo - (x.hi - x.lo))[0];
1136
+ if (!pick) return null;
1137
+ // The orb may run off the canvas edge (it's decoration), but never into a text block.
1138
+ const lo = pick.lo === 0 ? -Infinity : pick.lo;
1139
+ const hi = pick.hi === H ? Infinity : pick.hi;
1140
+ const r = Math.min(ORB_RADIUS * a.W, (hi - lo) / 2);
1141
+ if (!(r >= ORB_MIN_RADIUS * a.W)) return null;
1142
+ const cy = Math.min(Math.max(want, lo + r), hi - r);
1143
+ return { cy, r };
1144
+ };
1145
+
1146
+ // Pass 3: layers per screen.
1147
+ const metrics: ComposeScreenMetrics[] = [];
1148
+ const textRectsByScreen = new Map<number, TextRects>();
1149
+ const screens = resolved.map((r) => {
1150
+ const { W, H, typo, block, plan: screen } = r;
1151
+ const p = placements.get(r.index)!;
1152
+ const layers: LayerJSON[] = [];
1153
+ let background = r.background;
1154
+
1155
+ // Panorama background + seam decoration (BOTTOM of the stack).
1156
+ const inSpan = spanOf.get(r.index);
1157
+ if (inSpan) {
1158
+ const { span, k } = inSpan;
1159
+ const N = span.length;
1160
+ // The span's FIRST screen's (already resolved) background runs across the whole span.
1161
+ const fill = spanFills.get(span[0])!;
1162
+ background = { type: 'solid', color: fill.kind === 'solid' ? fill.color : fill.stops[0].color };
1163
+ const bg = makeShapeLayer({
1164
+ shape: 'rectangle',
1165
+ left: (N * W) / 2 - k * W,
1166
+ top: H / 2,
1167
+ width: N * W,
1168
+ height: H,
1169
+ name: `Panorama background (${k + 1}/${N})`,
1170
+ locked: true
1171
+ });
1172
+ Object.assign(bg.fabricData as Record<string, unknown>, {
1173
+ fill:
1174
+ fill.kind === 'solid'
1175
+ ? fill.color
1176
+ : {
1177
+ type: 'linear',
1178
+ gradientUnits: 'pixels',
1179
+ coords: { ...fill.coords },
1180
+ colorStops: fill.stops.map((s) => ({ offset: s.offset, color: s.color })),
1181
+ offsetX: 0,
1182
+ offsetY: 0
1183
+ },
1184
+ selectable: false,
1185
+ evented: false
1186
+ });
1187
+ layers.push(bg);
1188
+ if ((style.panorama?.decoration ?? 'orbs') === 'orbs') {
1189
+ // A soft orb centred on each seam touching this screen, kept clear of both text blocks.
1190
+ for (let seam = 1; seam < N; seam++) {
1191
+ if (seam !== k && seam !== k + 1) continue;
1192
+ const orb = orbFor(span, seam);
1193
+ if (!orb) continue;
1194
+ layers.push(
1195
+ makeShapeLayer({
1196
+ shape: 'circle',
1197
+ left: seam * W - k * W,
1198
+ top: orb.cy,
1199
+ radius: orb.r,
1200
+ fill: 'rgba(255,255,255,0.14)',
1201
+ name: 'Panorama orb'
1202
+ })
1203
+ );
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ // A device straddling in from the previous screen sits under this screen's own subject.
1209
+ for (const [from, s] of straddle) {
1210
+ if (s.into !== r.index) continue;
1211
+ const fr = resolved[from];
1212
+ const fp = placements.get(from)!;
1213
+ const cont = makeSubjectLayer(fr, fp, s.cx - W, `${getDeviceFrame(fr.plan.deviceId)?.name ?? 'Screenshot'} (continued)`);
1214
+ layers.push(cont);
1215
+ }
1216
+
1217
+ const cx = straddle.get(r.index)?.cx ?? p.cx;
1218
+ layers.push(makeSubjectLayer(r, p, cx));
1219
+
1220
+ // Text block: badge row → headline → subheadline, anchored to the reserved text area.
1221
+ const areaTop = r.layout === 'text-bottom' ? H - typo.margin - typo.textArea : typo.margin;
1222
+ const rects: TextRects = {
1223
+ headline: { left: (W - typo.textWidth) / 2, top: 0, right: (W + typo.textWidth) / 2, bottom: 0 }
1224
+ };
1225
+ // Auto text colour from the background ACTUALLY behind the text block (after the panorama
1226
+ // swap): sample the span gradient / screen background over the block. An explicit
1227
+ // headlineColor wins; explicit (non-palette) backgrounds outside a span keep the white default.
1228
+ const blockBottom = areaTop + typo.badgeRow + block.headlineHeight + block.gap + block.subHeight;
1229
+ const samples: string[] = [];
1230
+ for (const fx of [0.1, 0.5, 0.9]) {
1231
+ for (const fy of [0, 0.5, 1]) {
1232
+ const x = fx * W;
1233
+ const y = areaTop + fy * (blockBottom - areaTop);
1234
+ samples.push(inSpan ? samplePanorama(spanFills.get(inSpan.span[0])!, inSpan.k, W, x, y) : sampleBackground(r.background, W, H, x, y));
1235
+ }
1236
+ }
1237
+ const headlineColor = screen.headlineColor ?? (inSpan || r.backgroundFromStyle ? readableTextOn(samples) : '#ffffff');
1238
+ if (screen.badge?.trim()) {
1239
+ const text = screen.badge.trim();
1240
+ if (text.length > BADGE_MAX_CHARS) {
1241
+ warnings.push({ screen: r.index, code: 'badge-long', message: `screen ${r.index + 1}: badge "${text}" is long — keep it to 1–3 words` });
1242
+ }
1243
+ const fontSize = typo.badgeFont;
1244
+ const pillH = fontSize * BADGE_HEIGHT;
1245
+ const pillW = Math.min(typo.textWidth, estimateTextWidth(text, fontSize, typo.font) + 2 * BADGE_PAD_X * fontSize);
1246
+ const cy = areaTop + pillH / 2;
1247
+ const pill = makeShapeLayer({
1248
+ shape: 'rectangle',
1249
+ left: W / 2,
1250
+ top: cy,
1251
+ width: pillW,
1252
+ height: pillH,
1253
+ rx: pillH / 2,
1254
+ ry: pillH / 2,
1255
+ fill: rgba(isHexColor(headlineColor) ? headlineColor : '#ffffff', 0.18),
1256
+ name: 'Badge'
1257
+ });
1258
+ const label = makeTextLayer({
1259
+ text,
1260
+ left: W / 2,
1261
+ top: cy,
1262
+ width: pillW,
1263
+ fontSize,
1264
+ fontFamily: font,
1265
+ fontWeight: '700',
1266
+ lineHeight: 1,
1267
+ fill: headlineColor,
1268
+ textAlign: 'center',
1269
+ name: 'Badge text',
1270
+ templateRole: 'editable',
1271
+ templateKey: 'badge'
1272
+ });
1273
+ layers.push(pill, label);
1274
+ rects.badge = { left: W / 2 - pillW / 2, top: areaTop, right: W / 2 + pillW / 2, bottom: areaTop + pillH };
1275
+ }
1276
+ const headlineTop = areaTop + typo.badgeRow;
1277
+ rects.headline.top = headlineTop;
1278
+ rects.headline.bottom = headlineTop + block.headlineHeight;
1279
+
1280
+ // Center origin (editor convention): left/top are the box CENTER.
1281
+ layers.push(
1282
+ makeTextLayer({
1283
+ text: screen.headline,
1284
+ left: W / 2,
1285
+ top: headlineTop + block.headlineHeight / 2,
1286
+ width: typo.textWidth,
1287
+ fontSize: typo.headlineSize,
1288
+ fontFamily: font,
1289
+ fontWeight: '800',
1290
+ lineHeight: HEADLINE_LINE_HEIGHT,
1291
+ fill: headlineColor,
1292
+ textAlign: 'center',
1293
+ name: 'Headline',
1294
+ templateRole: 'editable',
1295
+ templateKey: 'headline'
1296
+ })
1297
+ );
227
1298
 
228
1299
  if (screen.subheadline?.trim()) {
1300
+ const subTop = headlineTop + block.headlineHeight + block.gap;
229
1301
  const sub = makeTextLayer({
230
1302
  text: screen.subheadline,
231
1303
  left: W / 2,
232
- top: blockTop + block.headlineHeight + block.gap + block.subHeight / 2,
233
- width: textWidth,
234
- fontSize: block.subSize,
1304
+ top: subTop + block.subHeight / 2,
1305
+ width: typo.textWidth,
1306
+ fontSize: typo.subSize,
1307
+ fontFamily: font,
235
1308
  fontWeight: '500',
236
1309
  lineHeight: SUBHEADLINE_LINE_HEIGHT,
237
1310
  fill: screen.subheadlineColor ?? headlineColor,
@@ -244,10 +1317,34 @@ export function composeTemplate(plan: ComposePlan): Template {
244
1317
  // in the editor). An explicit subheadlineColor is used as-is.
245
1318
  if (!screen.subheadlineColor) (sub.fabricData as Record<string, unknown>).opacity = SUBHEADLINE_OPACITY;
246
1319
  layers.push(sub);
1320
+ rects.sub = { left: rects.headline.left, top: subTop, right: rects.headline.right, bottom: subTop + block.subHeight };
247
1321
  }
1322
+ textRectsByScreen.set(r.index, rects);
1323
+
1324
+ const top = p.cy - p.boxHeight / 2;
1325
+ const bottom = p.cy + p.boxHeight / 2;
1326
+ const overshoot = r.layout === 'text-bottom' ? -top : bottom - H;
1327
+ metrics.push({
1328
+ index: r.index,
1329
+ presentation: r.presentation,
1330
+ layout: r.layout,
1331
+ group: r.group,
1332
+ scale: p.scale,
1333
+ widthFraction: (p.subject.width * p.scale) / W,
1334
+ top: top / H,
1335
+ bottom: bottom / H,
1336
+ overshoot: overshoot / H,
1337
+ bleedFraction: p.boxHeight > 0 ? overshoot / p.boxHeight : 0,
1338
+ mode: overshoot > 0 ? 'bleed' : 'clear',
1339
+ tangent: inTangentZone(overshoot, p.boxHeight, H),
1340
+ headlineSize: typo.headlineSize / W,
1341
+ headlineTop: headlineTop / H,
1342
+ tilt: p.angle,
1343
+ centerX: cx / W
1344
+ });
248
1345
 
249
1346
  return makeScreen({
250
- background: screen.background,
1347
+ background,
251
1348
  canvasWidth: W,
252
1349
  canvasHeight: H,
253
1350
  // Tag the device GROUP (multi-device) so a mixed plan lands as separate sidebar groups in
@@ -257,5 +1354,71 @@ export function composeTemplate(plan: ComposePlan): Template {
257
1354
  });
258
1355
  });
259
1356
 
260
- return makeTemplate({ name: plan.name, screens, tags: ['generated'] });
1357
+ // Copy + composition lint (warnings, never errors).
1358
+ resolved.forEach((r) => {
1359
+ const words = r.plan.headline.trim().split(/\s+/).filter(Boolean).length;
1360
+ const n = r.index + 1;
1361
+ if (words > COPY_RULES.headlineMaxWords) {
1362
+ warnings.push({ screen: r.index, code: 'headline-words', message: `screen ${n}: headline has ${words} words (aim for 3–5) — cut, don't shrink` });
1363
+ }
1364
+ if (r.block.headlineLines > COPY_RULES.headlineMaxLines) {
1365
+ warnings.push({
1366
+ screen: r.index,
1367
+ code: 'headline-lines',
1368
+ message: `screen ${n}: headline wraps to ~${r.block.headlineLines} lines at the set size (max 2) — it grows the text area for EVERY screen`
1369
+ });
1370
+ }
1371
+ if (r.block.subLines > COPY_RULES.subheadlineMaxLines) {
1372
+ warnings.push({
1373
+ screen: r.index,
1374
+ code: 'subheadline-lines',
1375
+ message: `screen ${n}: subheadline wraps to ~${r.block.subLines} lines (max 1) — shorten it or drop it`
1376
+ });
1377
+ }
1378
+ const p = placements.get(r.index)!;
1379
+ if (p.zoom?.focusCropped) {
1380
+ warnings.push({ screen: r.index, code: 'zoom-focus-cropped', message: `screen ${n}: the zoom card can't show the whole focus band — mark a tighter \`crop\`` });
1381
+ }
1382
+ });
1383
+ if (straddle.size > MAX_STRADDLES) {
1384
+ warnings.push({
1385
+ code: 'panorama-straddle-count',
1386
+ message: `${straddle.size} devices cross a seam — keep it to ${MAX_STRADDLES} per set (usually the hero) so the set stays calm`
1387
+ });
1388
+ }
1389
+ const tilted = resolved.filter((r) => r.tilt !== 0).length;
1390
+ if (tilted > COPY_RULES.maxTiltedScreens) {
1391
+ warnings.push({ code: 'tilt-count', message: `${tilted} screens are tilted — rotation is an accent: use it on at most ${COPY_RULES.maxTiltedScreens}` });
1392
+ }
1393
+ // Seam rule: nothing crossing a seam may touch a headline, and no focus band may cross a seam.
1394
+ for (const [from, s] of straddle) {
1395
+ const fr = resolved[from];
1396
+ const fp = placements.get(from)!;
1397
+ for (const [screenIdx, cx] of [
1398
+ [from, s.cx],
1399
+ [s.into, s.cx - fr.W]
1400
+ ] as const) {
1401
+ const box = { left: cx - fp.boxWidth / 2, right: cx + fp.boxWidth / 2, top: fp.cy - fp.boxHeight / 2, bottom: fp.cy + fp.boxHeight / 2 };
1402
+ const t = textRectsByScreen.get(screenIdx)!;
1403
+ if ([t.headline, t.sub, t.badge].some((rect) => rect && rectsOverlap(rect, box))) {
1404
+ warnings.push({ screen: screenIdx, code: 'panorama-seam', message: `screen ${screenIdx + 1}: the straddling device overlaps the text` });
1405
+ }
1406
+ }
1407
+ const poly = focusPolygon(fr, fp, s.cx);
1408
+ if (poly && Math.max(...poly.map((pt) => pt.x)) > fr.W) {
1409
+ warnings.push({ screen: from, code: 'panorama-seam', message: `screen ${from + 1}: the seam crosses the focus band` });
1410
+ }
1411
+ }
1412
+
1413
+ metrics.sort((a, b) => a.index - b.index);
1414
+ return { template: makeTemplate({ name: plan.name, screens, tags: ['generated'] }), report: { warnings, screens: metrics } };
1415
+ }
1416
+
1417
+ /**
1418
+ * Deterministically assemble a Template from a plan (see `composeSet` for the set-wide layout rules
1419
+ * and the lint report). Text layers are marked editable so the user can tweak them in the editor.
1420
+ * Claude decides the plan (benefit, copy, device, palette, layout, style); this turns it into valid DSL.
1421
+ */
1422
+ export function composeTemplate(plan: ComposePlan): Template {
1423
+ return composeSet(plan).template;
261
1424
  }