@appshoteditor/shot-dsl 0.3.0 → 0.5.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,38 @@
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, isUploadedScreenshotSrc } 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 { contrastRatio, darken, isHexColor, lighten, mixHex, rgba } from './color';
23
+ import { breakLines, hasOrphan, isWideChar, overlongWords } from './typography';
24
+ import {
25
+ MIN_CONTRAST,
26
+ harmonize,
27
+ hexToHsl,
28
+ hslToHex,
29
+ shiftBackground,
30
+ textCandidates,
31
+ tonalBackground,
32
+ worstContrast,
33
+ type PaletteTone
34
+ } from './palette';
35
+ import { MOTIFS, focusCoverage, insideRect, motifPathForScreen, motifSubpaths, overlaps, rectOf, type Motif, type PathCommand, type Rect } from './decor';
5
36
 
6
37
  /**
7
38
  * Editor-unit canvas dimensions for a device, by frame class — so a plan that mixes iPhone, iPad,
@@ -9,7 +40,7 @@ import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
9
40
  * Page Setup presets (phone 280×608, tablet 450×600, laptop/desktop 608×380). Export scales these
10
41
  * up to the real App Store pixel sizes.
11
42
  */
12
- function canvasDimsForDevice(deviceId: string): { width: number; height: number } {
43
+ export function canvasDimsForDevice(deviceId: string): { width: number; height: number } {
13
44
  switch (getDeviceFrame(deviceId)?.category) {
14
45
  case 'tablet':
15
46
  return { width: 450, height: 600 };
@@ -24,15 +55,154 @@ function canvasDimsForDevice(deviceId: string): { width: number; height: number
24
55
 
25
56
  /**
26
57
  * 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.
58
+ * - `text-top` (default): headline (+ subheadline) at the top, device below.
59
+ * - `text-bottom`: device at the top (bleeding off the TOP edge when it bleeds), text block at the bottom.
60
+ * - `device-bleed`: text at the top, the device (≤ 90% of the width) pushed low and bleeding more — the "hero" look.
31
61
  */
32
62
  export type ComposeLayout = 'text-top' | 'text-bottom' | 'device-bleed';
33
-
34
63
  export const COMPOSE_LAYOUTS: readonly ComposeLayout[] = ['text-top', 'text-bottom', 'device-bleed'];
35
64
 
65
+ /**
66
+ * How a screenshot is presented:
67
+ * - `device` (default): inside a device frame mockup.
68
+ * - `frameless`: the bare screenshot with rounded corners + a soft shadow.
69
+ * - `zoom`: a magnified crop of the screen's selling UI (`crop`, else the `focus` band), rounded + shadow.
70
+ */
71
+ export type ComposePresentation = 'device' | 'frameless' | 'zoom';
72
+ export const COMPOSE_PRESENTATIONS: readonly ComposePresentation[] = ['device', 'frameless', 'zoom'];
73
+
74
+ /** `auto` (default): the no-tangent rule picks clear-with-margin or a decisive bleed. */
75
+ export type ComposeBleed = BleedPreference;
76
+ export const COMPOSE_BLEEDS: readonly ComposeBleed[] = ['auto', 'none', 'deep'];
77
+
78
+ /** Fonts the editor offers (Inter is web-loaded; the rest are system fonts). */
79
+ export const COMPOSE_FONTS: readonly string[] = [
80
+ 'Inter',
81
+ 'Arial',
82
+ 'Helvetica',
83
+ 'Georgia',
84
+ 'Times New Roman',
85
+ 'Courier New',
86
+ 'Verdana',
87
+ 'Trebuchet MS',
88
+ 'Impact',
89
+ 'Comic Sans MS'
90
+ ];
91
+
92
+ /** Fractions (0–1) of the screenshot: a crop rectangle for `zoom`. */
93
+ export interface ComposeCrop {
94
+ x: number;
95
+ y: number;
96
+ w: number;
97
+ h: number;
98
+ }
99
+
100
+ /** Set-wide background system, used for screens that omit `background`. */
101
+ export interface ComposePalette {
102
+ /**
103
+ * `family`: every screen shares one gradient through `colors`. `sequence`: screen i uses
104
+ * colors[i % n]. `tonal` (0.5.0): `colors` = [base, accent?] brand colours → light↔deep steps of
105
+ * the base hue per screen (`tone`), the accent for badges / motif. Every mode gets ONE set-wide
106
+ * text colour that passes WCAG ≥ 4.5 on every palette background (tones are adjusted until it does).
107
+ */
108
+ mode: 'family' | 'sequence' | 'tonal';
109
+ colors: string[];
110
+ /** `tonal` only: `light` (pale tints, dark text), `vivid` (the brand colour itself, default), `deep`. */
111
+ tone?: PaletteTone;
112
+ }
113
+
114
+ export const COMPOSE_PALETTE_TONES: readonly PaletteTone[] = ['light', 'vivid', 'deep'];
115
+
116
+ export interface ComposePanorama {
117
+ /**
118
+ * Runs of adjacent screen indices sharing one continuous background, e.g. [[0, 1], [2, 3]]. May be
119
+ * omitted (no spans) — e.g. a plan that only picks the `decoration` concept C of `makeVariants` uses.
120
+ */
121
+ spans?: number[][];
122
+ /**
123
+ * Devices that cross a seam into the next screen: `true` = the first screen of EVERY span, or a
124
+ * list of screen indices (each must be the first screen of a span). Default none. More than one
125
+ * straddle per set is allowed but lint-warned (`panorama-straddle-count`).
126
+ */
127
+ straddle?: boolean | number[];
128
+ /**
129
+ * Seam decoration. `orbs` (default): soft circles on each seam. `honeycomb` / `wave` (0.5.0): one
130
+ * continuous motif path across the whole span, crossing every seam. `none`.
131
+ */
132
+ decoration?: Motif;
133
+ }
134
+
135
+ /** Brand art (a mascot, a logo mark) uploaded like a screenshot (transparent PNG). */
136
+ export interface ComposeArt {
137
+ id: string;
138
+ /** Uploaded-asset URL (/api/screenshots/<id>/raw) — same rule as screenshots. */
139
+ url: string;
140
+ width: number;
141
+ height: number;
142
+ /** Which way the art faces; lets the composer flip it to face into the canvas. */
143
+ faces?: 'left' | 'right';
144
+ }
145
+
146
+ export type MascotAnchor = 'headline' | 'device-top' | 'device-side' | 'seam';
147
+ export const MASCOT_ANCHORS: readonly MascotAnchor[] = ['headline', 'device-top', 'device-side', 'seam'];
148
+
149
+ export interface ComposeMascot {
150
+ /** `art[].id` of the plan. */
151
+ art: string;
152
+ /** Where it goes (fallbacks are tried when it doesn't fit). Default `headline`. */
153
+ anchor?: MascotAnchor;
154
+ /** Rendered width ÷ W. Default 0.3 on the hero, 0.22 elsewhere. */
155
+ size?: number;
156
+ /** Mirror it horizontally (default: face into the canvas when the art declares `faces`). */
157
+ flip?: boolean;
158
+ }
159
+
160
+ /** The hero (screen 1 by default): larger type, optional mascot + badge, deeper bleed and/or tilt. */
161
+ export interface ComposeHero {
162
+ /** Hero screen index. Default 0. */
163
+ screen?: number;
164
+ /** Headline scale vs the set size (1–1.4). Default 1.25 (reduced when it would add a line). */
165
+ scale?: number;
166
+ layout?: ComposeLayout;
167
+ /** Default `deep` (unless the set's bleed is `none`). */
168
+ bleed?: ComposeBleed;
169
+ /** Degrees; default: the screen's own tilt. */
170
+ tilt?: number;
171
+ mascot?: ComposeMascot;
172
+ /** Hero badge (else the screen's own `badge`). */
173
+ badge?: string;
174
+ }
175
+
176
+ /** Accent rhythm: every `every`-th screen (4th, 8th… for 4) gets a different treatment. */
177
+ export interface ComposeRhythm {
178
+ /** 3–6. */
179
+ every: number;
180
+ /** `text-bottom` (default): device on top, text below. `callout`: a magnified callout + deeper bleed. */
181
+ treatment?: 'text-bottom' | 'callout';
182
+ }
183
+
184
+ /** Plan-level style (all optional; an empty style composes exactly like a plan without one). */
185
+ export interface ComposeStyle {
186
+ presentation?: ComposePresentation;
187
+ /** Degrees (clockwise) for the accented screens listed in `tiltScreens`. Default 0. */
188
+ tilt?: number;
189
+ /** Screen indices that get `tilt`. Default: none. */
190
+ tiltScreens?: number[];
191
+ bleed?: ComposeBleed;
192
+ palette?: ComposePalette;
193
+ panorama?: ComposePanorama;
194
+ /** Font family for all text (one of COMPOSE_FONTS). Default Inter. */
195
+ font?: string;
196
+ /** 0.5.0: the hero screen. Default on for screen 1 (`{}`); `false` turns it off. */
197
+ hero?: ComposeHero | false;
198
+ /** 0.5.0: accent rhythm (default none). */
199
+ rhythm?: ComposeRhythm;
200
+ /** 0.5.0: `auto` derives a magnified callout from `focus` on screens without an explicit one. Default `none`. */
201
+ callouts?: 'auto' | 'none';
202
+ /** 0.5.0: soft drop shadows on devices, frameless shots, callouts and mascots. Default true. */
203
+ shadows?: boolean;
204
+ }
205
+
36
206
  /** One screen's worth of plan input (the skill decides these per benefit). */
37
207
  export interface ComposeScreenPlan {
38
208
  headline: string;
@@ -43,9 +213,30 @@ export interface ComposeScreenPlan {
43
213
  subheadlineColor?: string;
44
214
  /** Defaults to `text-top`. */
45
215
  layout?: ComposeLayout;
46
- background: BackgroundJSON;
216
+ /**
217
+ * Required unless the plan has `style.palette`. In a panorama span every screen shows the span's
218
+ * FIRST screen's background.
219
+ */
220
+ background?: BackgroundJSON;
47
221
  screenshot: { url: string; width: number; height: number };
48
222
  deviceId: string;
223
+ /** Fractions of the screenshot height that must stay visible (the selling UI). */
224
+ focus?: FocusBand;
225
+ /** Zoom crop (fractions of the screenshot); defaults to the focus band. */
226
+ crop?: ComposeCrop;
227
+ /** Overrides `style.presentation` for this screen. */
228
+ presentation?: ComposePresentation;
229
+ /** Overrides the style tilt for this screen (degrees, clockwise). */
230
+ tilt?: number;
231
+ /** Short social-proof pill above the headline, e.g. "Teacher-approved". */
232
+ badge?: string;
233
+ /**
234
+ * 0.5.0: magnified callout of the selling element — a crop (fractions of the screenshot) shown
235
+ * 1.6–2.2× larger, overlapping the device. `false` disables an auto callout on this screen.
236
+ */
237
+ callout?: ComposeCrop | false;
238
+ /** 0.5.0: mascot / brand art on this screen. */
239
+ mascot?: ComposeMascot;
49
240
  }
50
241
 
51
242
  export interface ComposePlan {
@@ -53,6 +244,84 @@ export interface ComposePlan {
53
244
  screens: ComposeScreenPlan[];
54
245
  canvasWidth?: number;
55
246
  canvasHeight?: number;
247
+ style?: ComposeStyle;
248
+ /** 0.5.0: brand art (mascots) referenced by `mascot.art`. */
249
+ art?: ComposeArt[];
250
+ }
251
+
252
+ export interface ComposeWarning {
253
+ /** Screen index, when the warning is about one screen. */
254
+ screen?: number;
255
+ code:
256
+ | 'headline-words'
257
+ | 'headline-lines'
258
+ | 'subheadline-lines'
259
+ | 'tilt-count'
260
+ | 'panorama-seam'
261
+ | 'straddle-skipped'
262
+ | 'tilt-reduced'
263
+ | 'panorama-straddle-count'
264
+ | 'zoom-focus-cropped'
265
+ | 'badge-long'
266
+ | 'headline-orphan'
267
+ | 'contrast-low'
268
+ | 'callout-skipped'
269
+ | 'mascot-skipped';
270
+ message: string;
271
+ }
272
+
273
+ /** Per-screen geometry summary (canvas-relative), for reports and tests. */
274
+ export interface ComposeScreenMetrics {
275
+ index: number;
276
+ presentation: ComposePresentation;
277
+ layout: ComposeLayout;
278
+ /** Screens with the same group share scale, baseline and far-edge treatment. */
279
+ group: string;
280
+ /** Rendered subject scale (device scaleX; image scaleX for frameless/zoom). */
281
+ scale: number;
282
+ /** Rendered (unrotated) subject width ÷ W. */
283
+ widthFraction: number;
284
+ /** Subject's rotated bounding-box top / bottom ÷ H. */
285
+ top: number;
286
+ bottom: number;
287
+ /** Far-edge overshoot ÷ H (> 0 bleeds off the canvas). */
288
+ overshoot: number;
289
+ /** Far-edge overshoot ÷ the subject's rendered (rotated) height. */
290
+ bleedFraction: number;
291
+ mode: 'clear' | 'bleed';
292
+ /** True if the far edge sits in the forbidden "just touching" band (never, by construction). */
293
+ tangent: boolean;
294
+ /** Headline font size ÷ W. */
295
+ headlineSize: number;
296
+ /** Headline box top ÷ H (identical across a set by construction). */
297
+ headlineTop: number;
298
+ tilt: number;
299
+ /** Horizontal centre ÷ W (≠ 0.5 for a straddling panorama device). */
300
+ centerX: number;
301
+ /** 0.5.0: `hero`, `accent` (rhythm) or `set` (the shared system). */
302
+ role: ScreenRole;
303
+ /** The headline as set, line by line. */
304
+ headlineLines: string[];
305
+ /** The headline text colour. */
306
+ textColor: string;
307
+ /**
308
+ * Where it comes from: `set` (the ONE set-wide colour), `screen` (this explicit background can't
309
+ * reach WCAG AA with the set colour, so it got its own), `plan` (an explicit headlineColor).
310
+ */
311
+ textColorSource: 'set' | 'screen' | 'plan';
312
+ /** Worst WCAG contrast of the text colour over this screen's background. */
313
+ contrast: number;
314
+ /** Callout card (canvas units), when emitted. */
315
+ callout?: Rect & { magnification: number; focusCover: number };
316
+ /** Mascot box on this screen (canvas units; may extend past a seam). */
317
+ mascot?: Rect;
318
+ }
319
+
320
+ export type ScreenRole = 'hero' | 'accent' | 'set';
321
+
322
+ export interface ComposeReport {
323
+ warnings: ComposeWarning[];
324
+ screens: ComposeScreenMetrics[];
56
325
  }
57
326
 
58
327
  // ---------------------------------------------------------------------------
@@ -66,20 +335,112 @@ export interface ComposePlan {
66
335
  * (tablet, laptop) it's capped by the height so text doesn't swallow a landscape canvas.
67
336
  */
68
337
  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
338
+ /** × unit — ONE headline size for the whole set (copy that doesn't fit is a lint warning, never a shrink). */
339
+ export const HEADLINE_SIZE = 0.085;
71
340
  const HEADLINE_LINE_HEIGHT = 1.1;
72
- const SUBHEADLINE_RATIO = 0.55; // subheadline size ÷ headline size
341
+ const SUBHEADLINE_RATIO = 0.5; // subheadline size ÷ headline size (a real second tier)
73
342
  const SUBHEADLINE_LINE_HEIGHT = 1.25;
74
- const SUBHEADLINE_OPACITY = 0.85;
75
- 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;
343
+ const SUBHEADLINE_WEIGHT = '600';
344
+ /** Subheadline = the text colour tinted this far toward the brand hue (kept only if it stays ≥ AA). */
345
+ const SUBHEADLINE_TINT = 0.18;
346
+ /** …and the tint is used only when it clears AA with this margin (never a 4.49 by rounding). */
347
+ const SUBHEADLINE_TINT_MIN = 4.6;
348
+ /** Hero headline scale (× the set size) and its bounds. */
349
+ const HERO_SCALE = 1.25;
350
+ const HERO_SCALE_RANGE = [1, 1.4] as const;
351
+ /**
352
+ * × W → 6% side padding each side. The width model is conservative (a line estimated to fit renders
353
+ * narrower), so the visible text keeps a wider margin than the box.
354
+ */
355
+ const TEXT_WIDTH = 0.88;
356
+ /**
357
+ * Conservative per-font text metrics for line-wrap estimates (and so the reserved text area):
358
+ * px per character "unit" (see `charUnits`; 1 unit ≈ an average lowercase letter) × the font size.
359
+ * Calibrated from node-canvas measurements of macOS system fonts (bold, mixed case, ALL CAPS,
360
+ * narrow/wide stress strings) + ~10% headroom, so an estimate of N lines never renders as N+1;
361
+ * see src/lib/utils/composeFonts.canvas.test.ts in the app. Inter (web font, not installed for the
362
+ * measurement) is set ~10% wider than Arial. Courier New is monospace: every character is 1 unit.
363
+ */
364
+ export const FONT_CHAR_WIDTH: Readonly<Record<string, number>> = {
365
+ Inter: 0.66,
366
+ Arial: 0.64,
367
+ Helvetica: 0.64,
368
+ Georgia: 0.72,
369
+ 'Times New Roman': 0.63,
370
+ 'Courier New': 0.66,
371
+ Verdana: 0.76,
372
+ 'Trebuchet MS': 0.68,
373
+ Impact: 0.64,
374
+ 'Comic Sans MS': 0.76
375
+ };
376
+ const MONOSPACE_FONTS = new Set(['Courier New']);
377
+ const AVG_CHAR_WIDTH = FONT_CHAR_WIDTH.Inter;
378
+
379
+ /** A font name (→ FONT_CHAR_WIDTH) or a raw px-per-unit factor. */
380
+ export type TextMetrics = string | number;
381
+ const metricsOf = (m: TextMetrics) =>
382
+ typeof m === 'number' ? { charWidth: m, mono: false } : { charWidth: FONT_CHAR_WIDTH[m] ?? AVG_CHAR_WIDTH, mono: MONOSPACE_FONTS.has(m) };
78
383
 
79
384
  const EDGE_MARGIN = 0.055; // × H — gap between the text block and the canvas edge
80
- const TEXT_GAP = 0.3; // × headline font size — headline ↔ subheadline gap
385
+ const TEXT_GAP = 0.45; // × headline font size — headline ↔ subheadline gap
81
386
  const DEVICE_GAP = 0.04; // × unit — text block ↔ device gap
82
387
 
388
+ /** Copy rules (lint). */
389
+ export const COPY_RULES = { headlineMaxWords: 5, headlineMaxLines: 2, subheadlineMaxLines: 1, maxTiltedScreens: 2 } as const;
390
+
391
+ // Badge pill (social proof), reserved as a row above the headline set-wide when any screen has one.
392
+ const BADGE_FONT = 0.36; // × headline size
393
+ const BADGE_PAD_X = 1.1; // × badge font
394
+ const BADGE_HEIGHT = 2.1; // × badge font
395
+ const BADGE_GAP = 0.45; // × headline size — badge ↔ headline
396
+ const BADGE_MAX_CHARS = 28;
397
+
398
+ // Frameless / zoom styling.
399
+ const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel → a touch narrower)
400
+ const FRAMELESS_RADIUS = 0.1; // × rendered width
401
+ const ZOOM_WIDTH = 0.88; // × W
402
+ const ZOOM_RADIUS = 0.05; // × W
403
+ const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
404
+ const SHADOW = { color: 'rgba(0,0,0,0.28)', blur: 0.07, offsetY: 0.025 }; // blur/offset × W
405
+ /** Device frames: `fabricData.deviceShadow` (canvas units) — the editor casts it from the screen area. */
406
+ const DEVICE_SHADOW = { blur: 0.075, offsetY: 0.03 };
407
+ const CALLOUT_SHADOW = { blur: 0.06, offsetY: 0.02 };
408
+ const MASCOT_SHADOW = { blur: 0.035, offsetY: 0.012 };
409
+
410
+ // Callouts (magnified selling element).
411
+ const CALLOUT_TARGET_WIDTH = 0.8; // × W — the card width aimed for
412
+ const CALLOUT_MARGIN = 0.035; // × W — side margin of the card
413
+ const CALLOUT_EDGE = 0.04; // × H — far-edge margin
414
+ const CALLOUT_TEXT_GAP = 0.02; // × H — gap under / over the text block
415
+ export const CALLOUT_MAG = { min: 1.6, max: 2.2, floor: 1.25 } as const;
416
+ /**
417
+ * A callout may cover its own source slice (the pop-out), but at most this share of the REST of the
418
+ * focus band — it is moved (up/down, out to a side edge) or made smaller, else skipped.
419
+ */
420
+ export const CALLOUT_MAX_FOCUS_COVER = 0.35;
421
+ const CALLOUT_RADIUS = 0.035; // × W
422
+ // Auto crop: fractions of the screenshot width (left-aligned — UI rows start at the left, so a cut
423
+ // at the right loses a chevron, not the first letters of a label); card h ÷ w.
424
+ const CALLOUT_AUTO = { x: 0.02, w: 0.64, aspect: 0.4 };
425
+ /** `callouts: "auto"` only derives a callout where the focus band is this tight (a specific selling element). */
426
+ export const CALLOUT_AUTO_MAX_FOCUS = 0.5;
427
+
428
+ // Mascot.
429
+ const MASCOT_SIZE = { hero: 0.3, screen: 0.22 }; // × W
430
+ const MASCOT_MARGIN = 0.02; // × W from the canvas edges
431
+ const MASCOT_PAD = 0.012; // × W clearance from text / callouts
432
+ const MASCOT_FOCUS_INSET = 0.1; // × W — the focus band's outer edges may be overlapped by this much
433
+
434
+ // Panorama.
435
+ const STRADDLE_OVERLAP = 0.3; // × the device's visible width that crosses the seam (target)
436
+ /** 0.5.0: at least this much of the device must show on the next screen, else no straddle (a sliver reads as a glitch). */
437
+ export const STRADDLE_MIN = 0.18;
438
+ const MAX_STRADDLES = 1; // seam crossings per set before a lint warning
439
+ const ORB_RADIUS = 0.34; // × W
440
+ const ORB_Y = 0.7; // × H
441
+ const ORB_MIN_RADIUS = 0.08; // × W — smaller than this, the orb is dropped
442
+ const ORB_TEXT_GAP = 0.02; // × H — orbs keep this far from a text block
443
+
83
444
  interface LayoutSpec {
84
445
  /** Target rendered device width as a fraction of the canvas width. */
85
446
  deviceWidth: number;
@@ -92,162 +453,1566 @@ interface LayoutSpec {
92
453
  const LAYOUTS: Record<ComposeLayout, LayoutSpec> = {
93
454
  'text-top': { deviceWidth: 0.86, maxBleed: 0.2, minDeviceTop: 0 },
94
455
  'text-bottom': { deviceWidth: 0.9, maxBleed: 0.2, minDeviceTop: 0 },
95
- 'device-bleed': { deviceWidth: 0.95, maxBleed: 0.4, minDeviceTop: 0.28 }
456
+ // Capped at NO_TANGENT.maxWidth (90% W — a 5% side margin); the hero differs by sitting lower.
457
+ 'device-bleed': { deviceWidth: 0.9, maxBleed: 0.4, minDeviceTop: 0.28 }
96
458
  };
97
459
 
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)));
460
+ /**
461
+ * Relative advance of a character (1 = an average lowercase letter). Capitals and m/w are wide,
462
+ * i/l/punctuation narrow — so ALL-CAPS or "Mm Ww" copy isn't under-estimated.
463
+ */
464
+ function charUnits(ch: string): number {
465
+ if (ch === ' ') return 0.5;
466
+ // CJK / kana / Hangul / fullwidth glyphs are ~1 em: 1 / 0.66 ≈ 1.52 units, + headroom.
467
+ if (isWideChar(ch)) return WIDE_CHAR_UNITS;
468
+ if ("iljI.,:;!|'’".includes(ch)) return 0.5;
469
+ if ('ftr()[]-–'.includes(ch)) return 0.7;
470
+ if ('mwMW'.includes(ch)) return 1.55;
471
+ if (ch >= 'A' && ch <= 'Z') return 1.3;
472
+ return 1;
473
+ }
474
+
475
+ /** Width (in average-letter units) of a CJK / fullwidth glyph: ~1 em ÷ the 0.66 em unit, + headroom. */
476
+ const WIDE_CHAR_UNITS = 1.6;
477
+
478
+ /** Estimated rendered width of `text` (single line) — the width model behind `estimateLines`. */
479
+ export function estimateTextWidth(text: string, fontSize: number, metrics: TextMetrics = 'Inter'): number {
480
+ const { charWidth, mono } = metricsOf(metrics);
481
+ return [...text].reduce((sum, ch) => sum + (isWideChar(ch) ? WIDE_CHAR_UNITS : mono ? 1 : charUnits(ch)), 0) * fontSize * charWidth;
482
+ }
483
+
484
+ /**
485
+ * Greedy word-wrap estimate (Fabric Textbox semantics: wrap at spaces) of how
486
+ * many lines `text` takes at `fontSize` in a box `width` wide, in the font `metrics` (a COMPOSE_FONTS
487
+ * name, or a raw px-per-unit factor).
488
+ */
489
+ export function estimateLines(text: string, fontSize: number, width: number, metrics: TextMetrics = 'Inter'): number {
490
+ const { charWidth, mono } = metricsOf(metrics);
491
+ const unit = fontSize * charWidth;
492
+ const maxUnits = Math.max(1, width / unit);
493
+ const cu = (ch: string) => (isWideChar(ch) ? WIDE_CHAR_UNITS : mono ? 1 : charUnits(ch));
494
+ const units = (w: string) => [...w].reduce((sum, ch) => sum + cu(ch), 0);
101
495
  let lines = 0;
102
496
  for (const paragraph of text.split('\n')) {
103
497
  let current = 0;
104
498
  lines++;
105
499
  for (const word of paragraph.split(/\s+/).filter(Boolean)) {
106
- const len = word.length;
500
+ const len = units(word);
107
501
  if (current === 0) {
108
502
  current = len;
109
- } else if (current + 1 + len <= maxChars) {
110
- current += 1 + len;
503
+ } else if (current + cu(' ') + len <= maxUnits) {
504
+ current += cu(' ') + len;
111
505
  } else {
112
506
  lines++;
113
507
  current = len;
114
508
  }
115
- // A single word longer than a line wraps mid-word in Fabric's Textbox.
116
- while (current > maxChars) {
509
+ // A word wider than the line does NOT wrap in Fabric's Textbox (it widens the box to the
510
+ // word — see `overlongWords`; composeSet rejects such copy). Unspaced CJK runs are one
511
+ // "word" here; counting them as wrapping per line keeps this estimate conservative.
512
+ while (current > maxUnits) {
117
513
  lines++;
118
- current -= maxChars;
514
+ current -= maxUnits;
119
515
  }
120
516
  }
121
517
  }
122
518
  return Math.max(1, lines);
123
519
  }
124
520
 
125
- interface TextBlock {
521
+ /** Set typography for one canvas size. */
522
+ interface Typography {
523
+ W: number;
524
+ H: number;
525
+ unit: number;
126
526
  headlineSize: number;
127
- headlineHeight: number;
128
527
  subSize: number;
528
+ textWidth: number;
529
+ margin: number;
530
+ deviceGap: number;
531
+ /** Reserved badge row (0 when no screen of this size has a badge). */
532
+ badgeRow: number;
533
+ badgeFont: number;
534
+ /** The set font (text metrics: FONT_CHAR_WIDTH). */
535
+ font: string;
536
+ /** Reserved text-block height = the TALLEST block among screens of this size. */
537
+ textArea: number;
538
+ }
539
+
540
+ interface TextBlock {
541
+ /** The headline as set (balanced / explicit breaks). */
542
+ headline: string[];
543
+ sub: string[];
544
+ headlineLines: number;
545
+ headlineHeight: number;
546
+ subLines: number;
129
547
  subHeight: number;
130
548
  gap: number;
131
549
  height: number;
132
550
  }
133
551
 
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;
552
+ /** Lines of `text` at `size` in the box: explicit `\n` + balanced breaks (see typography.ts). */
553
+ function setLines(text: string, size: number, t: Pick<Typography, 'textWidth' | 'font'>): string[] {
554
+ // A line the balancer had to leave raw (an over-long word) still wraps in Fabric; measureTextBlock
555
+ // counts it with estimateLines.
556
+ return breakLines(text, t.textWidth, (line) => estimateTextWidth(line, size, t.font));
557
+ }
139
558
 
559
+ function measureTextBlock(screen: ComposeScreenPlan, t: Omit<Typography, 'textArea'>): TextBlock {
560
+ const headline = setLines(screen.headline, t.headlineSize, t);
561
+ const headlineLines = headline.reduce((n, line) => n + estimateLines(line, t.headlineSize, t.textWidth, t.font), 0);
562
+ const headlineHeight = headlineLines * t.headlineSize * HEADLINE_LINE_HEIGHT;
140
563
  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 };
564
+ const sub = hasSub ? setLines(screen.subheadline!, t.subSize, t) : [];
565
+ const subLines = sub.reduce((n, line) => n + estimateLines(line, t.subSize, t.textWidth, t.font), 0);
566
+ const subHeight = subLines * t.subSize * SUBHEADLINE_LINE_HEIGHT;
567
+ const gap = hasSub ? t.headlineSize * TEXT_GAP : 0;
568
+ return { headline, sub, headlineLines, headlineHeight, subLines, subHeight, gap, height: t.badgeRow + headlineHeight + gap + subHeight };
569
+ }
570
+
571
+ /** Everything decided per screen before layers are built. */
572
+ interface ResolvedScreen {
573
+ index: number;
574
+ plan: ComposeScreenPlan;
575
+ W: number;
576
+ H: number;
577
+ layout: ComposeLayout;
578
+ presentation: ComposePresentation;
579
+ tilt: number;
580
+ background: BackgroundJSON;
581
+ backgroundFromStyle: boolean;
582
+ typo: Typography;
583
+ block: TextBlock;
584
+ group: string;
585
+ role: ScreenRole;
586
+ /** Bleed preference of this screen's group (the hero defaults to `deep`). */
587
+ bleed: BleedPreference;
588
+ /** Badge text (hero badge or the screen's own). */
589
+ badge?: string;
590
+ /** Callout crop request (fractions), resolved from `callout` / `style.callouts` / rhythm. */
591
+ calloutReq?: ComposeCrop;
592
+ mascot?: ComposeMascot;
593
+ }
594
+
595
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
596
+
597
+ function normalizeFocus(focus: FocusBand | undefined): FocusBand | undefined {
598
+ if (!focus || typeof focus.top !== 'number' || typeof focus.bottom !== 'number') return undefined;
599
+ const top = clamp01(Math.min(focus.top, focus.bottom));
600
+ const bottom = clamp01(Math.max(focus.top, focus.bottom));
601
+ return bottom > top ? { top, bottom } : undefined;
602
+ }
603
+
604
+ /** A background's colour stops (solid → a subtle two-stop ramp around it). */
605
+ function backgroundStops(bg: BackgroundJSON): ColorStop[] {
606
+ const stops = bg.gradient?.colorStops;
607
+ if (bg.type === 'gradient' && stops && stops.length > 0) return stops;
608
+ // v1 gradients list bare `colors` (evenly spaced), like the editor's migrateScreenLayersJSON.
609
+ const v1 = bg.gradient?.colors?.filter(isHexColor) ?? [];
610
+ if (bg.type === 'gradient' && v1.length > 0) return evenStops(v1.length === 1 ? [v1[0], v1[0]] : v1);
611
+ const c = isHexColor(bg.color) ? bg.color : '#1F2937';
612
+ return [
613
+ { offset: 0, color: lighten(c, 0.14) },
614
+ { offset: 1, color: darken(c, 0.14) }
615
+ ];
616
+ }
617
+
618
+ function evenStops(colors: string[]): ColorStop[] {
619
+ return colors.map((color, i) => ({ offset: colors.length > 1 ? i / (colors.length - 1) : 0, color }));
620
+ }
621
+
622
+ const linear = (colorStops: ColorStop[]): BackgroundJSON => ({ type: 'gradient', gradient: { type: 'linear', colorStops } });
623
+
624
+ /** Background from the plan-level palette (null when there is none / it's unusable). `step`: tonal step. */
625
+ function paletteBackground(palette: ComposePalette | undefined, index: number, step = index): BackgroundJSON | null {
626
+ const colors = (palette?.colors ?? []).filter(isHexColor);
627
+ if (!palette || colors.length === 0) return null;
628
+ if (palette.mode === 'tonal') {
629
+ const tone = (COMPOSE_PALETTE_TONES as readonly string[]).includes(palette.tone ?? '') ? palette.tone! : 'vivid';
630
+ return tonalBackground(colors[0], tone, step);
631
+ }
632
+ if (palette.mode === 'sequence') {
633
+ const c = colors[index % colors.length];
634
+ return linear([
635
+ { offset: 0, color: lighten(c, 0.16) },
636
+ { offset: 1, color: c }
637
+ ]);
638
+ }
639
+ return linear(evenStops(colors.length === 1 ? [colors[0], darken(colors[0], 0.22)] : colors));
640
+ }
641
+
642
+ /** Colour of a ramp of `stops` at parameter t ∈ [0, 1] (non-hex stops are ignored). */
643
+ function colorAt(stops: ColorStop[], t: number): string {
644
+ const sorted = stops.filter((c) => isHexColor(c.color)).sort((a, b) => a.offset - b.offset);
645
+ if (sorted.length === 0) return '#1F2937';
646
+ if (t <= sorted[0].offset) return sorted[0].color;
647
+ for (let i = 1; i < sorted.length; i++) {
648
+ const a = sorted[i - 1];
649
+ const b = sorted[i];
650
+ if (t <= b.offset) return mixHex(a.color, b.color, b.offset > a.offset ? (t - a.offset) / (b.offset - a.offset) : 1);
651
+ }
652
+ return sorted[sorted.length - 1].color;
653
+ }
654
+
655
+ type Coords = { x1: number; y1: number; x2: number; y2: number };
656
+
657
+ /** Ramp parameter (0–1, padded at the ends) of point (x, y) on a linear gradient from p1 to p2. */
658
+ function rampT(c: Coords, x: number, y: number): number {
659
+ const dx = c.x2 - c.x1;
660
+ const dy = c.y2 - c.y1;
661
+ const len2 = dx * dx + dy * dy || 1;
662
+ return clamp01(((x - c.x1) * dx + (y - c.y1) * dy) / len2);
663
+ }
664
+
665
+ /** Where the editor draws an angle-based gradient over a w×h box (mirrors `deserializeBackground`). */
666
+ function angleCoords(angle: number | undefined, w: number, h: number): Coords {
667
+ const rad = (((angle || 180) - 90) * Math.PI) / 180;
668
+ return {
669
+ x1: (0.5 + Math.cos(rad) * 0.5) * w,
670
+ y1: (0.5 + Math.sin(rad) * 0.5) * h,
671
+ x2: (0.5 - Math.cos(rad) * 0.5) * w,
672
+ y2: (0.5 - Math.sin(rad) * 0.5) * h
673
+ };
147
674
  }
148
675
 
149
676
  /**
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.
677
+ * Colour of a LINEAR screen background at canvas point (x, y), placed exactly as the editor draws it
678
+ * (mirrors `deserializeBackground`: explicit coords, else `angle` — default 180°, i.e. offset 0 at
679
+ * the BOTTOM edge). Radial gradients are not modelled (they are sampled as if linear): auto-contrast
680
+ * only ever samples palette backgrounds, which are always linear, and panorama spans, which reject
681
+ * radial backgrounds.
155
682
  */
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
683
+ export function sampleBackground(bg: BackgroundJSON, W: number, H: number, x: number, y: number): string {
684
+ if (bg.type !== 'gradient' || !bg.gradient) return isHexColor(bg.color) ? bg.color : '#1F2937';
685
+ return colorAt(backgroundStops(bg), rampT(bg.gradient.coords ?? angleCoords(bg.gradient.angle, W, H), x, y));
686
+ }
687
+
688
+ /** What a panorama span paints across its N·W × H rect. */
689
+ type SpanFill = { kind: 'solid'; color: string } | { kind: 'linear'; stops: ColorStop[]; coords: Coords };
690
+
691
+ /**
692
+ * The span-start screen's background, stretched across the whole span, keeping its colours, stop
693
+ * offsets and `angle` (an angle-less gradient runs diagonally corner to corner; a solid colour stays
694
+ * solid). Rejects what can't run across screens: radial gradients and explicit per-screen `coords`.
695
+ */
696
+ function spanFillFor(bg: BackgroundJSON, first: number, N: number, W: number, H: number): SpanFill {
697
+ if (bg.type !== 'gradient' || !bg.gradient) return { kind: 'solid', color: isHexColor(bg.color) ? bg.color : '#1F2937' };
698
+ const at = `panorama span starting at screen ${first + 1}`;
699
+ if (bg.gradient.type === 'radial') {
700
+ throw new Error(`${at}: a radial gradient background can't run across screens — use a linear gradient or a solid colour`);
701
+ }
702
+ if (bg.gradient.coords) {
703
+ throw new Error(`${at}: explicit gradient \`coords\` are per-screen and can't span screens — use \`angle\` (or omit it) instead`);
704
+ }
705
+ const stops = backgroundStops(bg);
706
+ const coords = bg.gradient.angle != null ? angleCoords(bg.gradient.angle, N * W, H) : { x1: 0, y1: 0, x2: N * W, y2: H };
707
+ return { kind: 'linear', stops, coords };
708
+ }
709
+
710
+ /** Colour of a span fill at point (x, y) of span screen k. */
711
+ function samplePanorama(fill: SpanFill, k: number, W: number, x: number, y: number): string {
712
+ return fill.kind === 'solid' ? fill.color : colorAt(fill.stops, rampT(fill.coords, k * W + x, y));
713
+ }
714
+
715
+ /** The subject (what is placed under the text) of a framed / frameless screen. */
716
+ function subjectFor(r: ResolvedScreen): Subject {
717
+ if (r.presentation === 'device') {
718
+ const device = getDeviceFrame(r.plan.deviceId);
719
+ if (!device) throw new Error(`Unknown device: ${r.plan.deviceId}`);
720
+ const { width, height } = device.imageDimensions;
721
+ return { width, height, screen: { ...device.screenBounds } };
722
+ }
723
+ const { width, height } = r.plan.screenshot;
724
+ return { width, height, screen: { x: 0, y: 0, width, height } };
725
+ }
726
+
727
+ interface Placement {
728
+ cx: number;
729
+ cy: number;
730
+ scale: number;
731
+ angle: number;
732
+ /** Rendered rotated bounding box. */
733
+ boxWidth: number;
734
+ boxHeight: number;
735
+ vertical: VerticalResult;
736
+ subject: Subject;
737
+ /** Straddlers only: validated rightward shift that makes the device cross the seam. */
738
+ straddleDx?: number;
739
+ /** zoom only */
740
+ zoom?: { cropX: number; cropY: number; cropW: number; cropH: number; focusCropped: boolean };
741
+ }
742
+
743
+ /** Near-edge start for a screen's subject: below (or above, for text-bottom) the reserved text area. */
744
+ function nearStart(t: Typography): number {
745
+ return t.margin + t.textArea + t.deviceGap;
746
+ }
747
+
748
+ /**
749
+ * Fit a zoom crop (natural px) to the card aspect `A` = height / width, centred on the requested
750
+ * region. An explicit `crop` keeps its full width (the model chose it); a focus-derived band always
751
+ * shows the full screenshot width (B1). The height always covers the requested band when the image
752
+ * allows (else `focusCropped`).
753
+ */
754
+ function fitCrop(
755
+ shot: { width: number; height: number },
756
+ req: { x: number; y: number; w: number; h: number },
757
+ A: number,
758
+ explicit: boolean
759
+ ): { cropX: number; cropY: number; cropW: number; cropH: number; focusCropped: boolean } {
760
+ // B1 (0.5.0): only an explicit `crop` narrows the card; a focus-derived zoom shows the FULL
761
+ // screenshot width (a half-width centre crop cut words off at both edges).
762
+ const minW = explicit ? req.w : shot.width;
763
+ let w = Math.min(shot.width, Math.max(minW, req.h / A));
764
+ let h = w * A;
765
+ if (h > shot.height) {
766
+ h = shot.height;
767
+ w = h / A;
768
+ }
769
+ const focusCropped = h < req.h - 0.5 || (explicit && w < req.w - 0.5);
770
+ const cx = req.x + req.w / 2;
771
+ const cy = req.y + req.h / 2;
772
+ const cropX = Math.min(Math.max(0, cx - w / 2), shot.width - w);
773
+ const cropY = Math.min(Math.max(0, cy - h / 2), shot.height - h);
774
+ return { cropX, cropY, cropW: w, cropH: h, focusCropped };
775
+ }
776
+
777
+ const HORIZONTAL_EPS = 1e-9;
778
+ /** A tilted device may shrink to this × its straight scale to keep its margins; else the tilt is reduced. */
779
+ const TILT_MIN_SCALE = 0.8;
780
+
781
+ /**
782
+ * The horizontal no-tangent + focus rules for one pose: the subject's VISIBLE extent keeps
783
+ * `sideMargin`·W from both side edges (the right edge is waived for a deliberate straddle), and
784
+ * every focus band's rotated corners stay `focusSideSafe`·W inside the edges.
785
+ */
786
+ function horizontalOk(
787
+ subject: Subject,
788
+ pose: { cx: number; cy: number; scale: number; angle: number },
789
+ W: number,
790
+ H: number,
791
+ focuses: FocusBand[],
792
+ straddleRight = false
793
+ ): boolean {
794
+ const eps = HORIZONTAL_EPS * W;
795
+ const ext = visibleXExtent(subject, pose, H);
796
+ if (ext) {
797
+ const m = NO_TANGENT.sideMargin * W;
798
+ if (ext.min < m - eps) return false;
799
+ if (!straddleRight && ext.max > W - m + eps) return false;
800
+ }
801
+ const fs = NO_TANGENT.focusSideSafe * W;
802
+ return focuses.every((f) => {
803
+ const fx = focusXExtent(subject, f, pose);
804
+ return fx.min >= fs - eps && fx.max <= W - fs + eps;
805
+ });
806
+ }
807
+
808
+ /**
809
+ * Rightward shift for a panorama straddle: the visible device crosses the seam by STRADDLE_OVERLAP
810
+ * of its visible width, limited so every focus band stays `focusSideSafe`·W inside the seam. Null
811
+ * when that leaves less than a decisive STRADDLE_MIN crossing (or would move the device left).
812
+ */
813
+ function straddleShift(
814
+ subject: Subject,
815
+ pose: { cx: number; cy: number; scale: number; angle: number },
816
+ W: number,
817
+ H: number,
818
+ focuses: FocusBand[]
819
+ ): number | null {
820
+ const ext = visibleXExtent(subject, pose, H);
821
+ if (!ext) return null;
822
+ const visW = ext.max - ext.min;
823
+ let dx = W + STRADDLE_OVERLAP * visW - ext.max;
824
+ for (const f of focuses) dx = Math.min(dx, W * (1 - NO_TANGENT.focusSideSafe) - focusXExtent(subject, f, pose).max);
825
+ const overlap = ext.max + dx - W;
826
+ return dx > 0 && overlap >= STRADDLE_MIN * visW ? dx : null;
827
+ }
828
+
829
+ const screensOf = (members: ResolvedScreen[]) => members.map((m) => m.index + 1).join(', ');
830
+
831
+ /** The copy is the cause: the device gets < `minScale` of what it would get with no copy at all. */
832
+ const copyTooLong = (members: ResolvedScreen[], W: number, H: number) =>
833
+ new Error(
834
+ `copy too long for this canvas (${W}×${H}, screen ${screensOf(members)}): the text leaves the device ` +
835
+ `less than ${Math.round(NO_TANGENT.minScale * 100)}% of the room it gets with no copy — cut the subheadline or headline`
836
+ );
837
+
838
+ /** The canvas is the cause: even with no copy the subject can't reach a usable size. */
839
+ const canvasMismatch = (members: ResolvedScreen[], W: number, H: number, what: string, pct: number) =>
840
+ new Error(
841
+ `${what} doesn't fit a ${W}×${H} canvas (screen ${screensOf(members)}): even with no copy it renders at only ` +
842
+ `${Math.round(pct * 100)}% of its target width — use the device's own canvas (omit canvasWidth/canvasHeight) or another device`
843
+ );
844
+
845
+ /** Below this × its target width even with NO copy, the canvas (not the copy) is the problem. */
846
+ const MIN_EMPTY_COPY_FIT = 0.3;
847
+
848
+ /**
849
+ * Pose of every screen in a group (they share scale, baseline and far-edge treatment).
850
+ * Vertical: `solveVertical` (no tangent, focus visible). Horizontal: the pose must pass
851
+ * `horizontalOk` for every member; if the tilt makes it too wide, the scale is capped (by at most
852
+ * TILT_MIN_SCALE), then the tilt is reduced in quarter steps down to 0 (a straight device ≤ 90% W
853
+ * always fits), with a `tilt-reduced` warning. Zoom cards follow the same ladder, keeping their
854
+ * aspect. Throws `copyTooLong` when the copy leaves < minScale of the no-copy fit, and
855
+ * `canvasMismatch` when even the no-copy fit is unusable (N1).
856
+ */
857
+ function placeGroup(
858
+ members: ResolvedScreen[],
859
+ bleed: BleedPreference,
860
+ warnings: ComposeWarning[],
861
+ /** Every member is a requested panorama straddler: the right side margin is waived (it crosses the seam). */
862
+ straddleRight = false
863
+ ): Map<number, Placement> {
864
+ const out = new Map<number, Placement>();
865
+ const first = members[0];
866
+ const { W, H, typo, layout, presentation, tilt } = first;
867
+ const spec = LAYOUTS[layout] ?? LAYOUTS['text-top'];
868
+ const farEdge = layout === 'text-bottom' ? 'top' : 'bottom';
869
+ const near = nearStart(typo);
870
+ const reduced = (to: number) => {
871
+ if (to === tilt) return;
872
+ warnings.push({
873
+ screen: first.index,
874
+ code: 'tilt-reduced',
875
+ 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`
876
+ });
877
+ };
878
+
879
+ if (presentation === 'zoom') {
880
+ // One card box for the group. Straight, it spans the full remaining height (clear of the far
881
+ // edge by the margin) at ZOOM_WIDTH·W, never flatter than ZOOM_MIN_ASPECT. That ASPECT is kept at
882
+ // every tilt: a tilted card scales down (≤ TILT_MIN_SCALE) until its rotated bounding box fits
883
+ // the remaining height and the 90%-W side margins; beyond that the tilt is reduced (quarter
884
+ // steps, `tilt-reduced`), and a straight card always fits.
885
+ const avail = H * (1 - NO_TANGENT.clearGap) - near;
886
+ const availEmpty = H * (1 - NO_TANGENT.clearGap) - (typo.margin + typo.deviceGap);
887
+ if (!(avail > 0) || avail < NO_TANGENT.minScale * availEmpty) throw copyTooLong(members, W, H);
888
+ let baseW = ZOOM_WIDTH * W;
889
+ const baseH = avail;
890
+ if (baseH < baseW * ZOOM_MIN_ASPECT) {
891
+ baseW = baseH / ZOOM_MIN_ASPECT;
892
+ }
893
+ const aspect = baseH / baseW;
894
+ const maxBoxWidth = (1 - 2 * NO_TANGENT.sideMargin) * W;
895
+ const fitAt = (angle: number) => {
896
+ const rad = (angle * Math.PI) / 180;
897
+ const sin = Math.abs(Math.sin(rad));
898
+ const cos = Math.abs(Math.cos(rad));
899
+ const k = Math.min(1, maxBoxWidth / (baseW * (cos + aspect * sin)), avail / (baseW * (sin + aspect * cos)));
900
+ return { k, boxW: k * baseW, boxH: k * baseW * aspect };
901
+ };
902
+ let angleUsed = 0;
903
+ let fit = fitAt(0);
904
+ for (const angle of tilt === 0 ? [0] : [tilt, tilt * 0.75, tilt * 0.5, tilt * 0.25, 0]) {
905
+ const f = fitAt(angle);
906
+ if (angle === 0 || f.k >= TILT_MIN_SCALE - 1e-9) {
907
+ angleUsed = angle;
908
+ fit = f;
909
+ break;
910
+ }
911
+ }
912
+ reduced(angleUsed);
913
+ const tiltUsed = angleUsed;
914
+ const { boxW, boxH } = fit;
915
+ const box = rotatedBox(boxW, boxH, tiltUsed);
916
+ for (const r of members) {
917
+ const shot = r.plan.screenshot;
918
+ const focus = normalizeFocus(r.plan.focus);
919
+ const crop = r.plan.crop;
920
+ const req = crop
921
+ ? { 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) }
922
+ : focus
923
+ ? { x: 0, y: focus.top * shot.height, w: shot.width, h: (focus.bottom - focus.top) * shot.height }
924
+ : { x: 0, y: 0, w: shot.width, h: Math.min(shot.height, shot.width * (boxH / boxW)) };
925
+ const zoom = fitCrop(shot, req, boxH / boxW, !!crop);
926
+ const scale = boxW / zoom.cropW;
927
+ const nearEdge = near;
928
+ const cy = farEdge === 'bottom' ? nearEdge + box.height / 2 : H - nearEdge - box.height / 2;
929
+ const overshoot = nearEdge + box.height - H;
930
+ out.set(r.index, {
931
+ cx: W / 2,
932
+ cy,
933
+ scale,
934
+ angle: tiltUsed,
935
+ boxWidth: box.width,
936
+ boxHeight: box.height,
937
+ vertical: { scale, near: nearEdge, overshoot, mode: 'clear', bleedFraction: overshoot / box.height, reason: 'zoom card clears the edge' },
938
+ subject: { width: zoom.cropW, height: zoom.cropH, screen: { x: 0, y: 0, width: zoom.cropW, height: zoom.cropH } },
939
+ zoom
940
+ });
941
+ }
942
+ return out;
943
+ }
944
+
945
+ const subject = subjectFor(first);
946
+ const targetWidth = Math.min(spec.deviceWidth * (presentation === 'frameless' ? FRAMELESS_WIDTH : 1), NO_TANGENT.maxWidth);
947
+ const targetScale = (targetWidth * W) / subject.width;
948
+ // Every member's focus band is honoured by the shared pose (the most restrictive one decides).
949
+ const focuses = members.map((r) => normalizeFocus(r.plan.focus)).filter((f): f is FocusBand => !!f);
950
+ const solve = (angle: number, scaleCap?: number, nearOverride?: number) => {
951
+ const box = rotatedBox(subject.width, subject.height, angle);
952
+ const reaches = focuses.map((f) => focusReach(subject, f, angle, farEdge));
953
+ const vertical = solveVertical({
954
+ canvasWidth: W,
955
+ canvasHeight: H,
956
+ near: nearOverride ?? near,
957
+ minNear: spec.minDeviceTop * H,
958
+ baseWidth: subject.width,
959
+ boxHeight: box.height,
960
+ targetWidth,
961
+ maxBleed: spec.maxBleed,
962
+ focusReach: reaches.length ? Math.max(...reaches) : null,
963
+ bleed,
964
+ preferBleed: angle !== 0,
965
+ scaleCap
966
+ });
967
+ const s = vertical.scale;
968
+ const cy = farEdge === 'bottom' ? vertical.near + (s * box.height) / 2 : H - vertical.near - (s * box.height) / 2;
969
+ return { vertical, box, cy, angle };
970
+ };
971
+
972
+ // L7 / N1: the room gate is relative to what the SAME subject gets on this canvas with NO copy
973
+ // (height-limited on landscape canvases), so a tall device on a squat canvas isn't blamed on the
974
+ // copy. Too small even with no copy ⇒ the canvas/device aspect is the problem.
975
+ const straight = solve(0);
976
+ const empty = solve(0, undefined, typo.margin + typo.deviceGap).vertical.scale;
977
+ if (!(empty >= MIN_EMPTY_COPY_FIT * targetScale - 1e-9)) {
978
+ throw canvasMismatch(members, W, H, presentation === 'device' ? `device ${first.plan.deviceId}` : 'this screenshot', empty / targetScale);
979
+ }
980
+ const minScale = NO_TANGENT.minScale * empty;
981
+ if (!(straight.vertical.scale >= minScale - 1e-9)) throw copyTooLong(members, W, H);
982
+
983
+ // A tilt may cost at most TILT_MIN_SCALE of the straight size; beyond that the tilt is reduced
984
+ // (in quarter steps down to 0) rather than shrinking the device further.
985
+ const tiltFloor = Math.max(minScale, TILT_MIN_SCALE * straight.vertical.scale);
986
+ // Search: tilt candidates (reduced in quarter steps), each with a shrinking scale cap. A straddler
987
+ // is judged AFTER its seam shift (right margin waived; left margin + focus vs seam checked); if no
988
+ // straddling pose exists it is placed as a normal screen (the caller then skips the straddle).
989
+ const search = (asStraddle: boolean) => {
990
+ for (const angle of tilt === 0 ? [0] : [tilt, tilt * 0.75, tilt * 0.5, tilt * 0.25, 0]) {
991
+ const floor = angle === 0 ? minScale : tiltFloor;
992
+ let cap: number | undefined;
993
+ for (let i = 0; i < 400; i++) {
994
+ const sol = solve(angle, cap);
995
+ const pose = { cx: W / 2, cy: sol.cy, scale: sol.vertical.scale, angle };
996
+ if (sol.vertical.scale >= floor - 1e-9) {
997
+ if (!asStraddle && horizontalOk(subject, pose, W, H, focuses)) return { sol, dx: undefined as number | undefined };
998
+ if (asStraddle) {
999
+ const dx = straddleShift(subject, pose, W, H, focuses);
1000
+ if (dx != null && horizontalOk(subject, { ...pose, cx: pose.cx + dx }, W, H, focuses, true)) return { sol, dx };
1001
+ }
1002
+ }
1003
+ const next = sol.vertical.scale * 0.985;
1004
+ if (next < floor) break;
1005
+ cap = next;
1006
+ }
1007
+ }
1008
+ return null;
1009
+ };
1010
+ // A straight, centred device at ≤ 90% W always passes; `straight` is the defensive fallback.
1011
+ const found = (straddleRight ? search(true) : null) ?? search(false) ?? { sol: straight, dx: undefined };
1012
+ const chosen = found.sol;
1013
+ reduced(chosen.angle);
1014
+ const s = chosen.vertical.scale;
1015
+ for (const r of members) {
1016
+ out.set(r.index, {
1017
+ cx: W / 2,
1018
+ cy: chosen.cy,
1019
+ scale: s,
1020
+ angle: chosen.angle,
1021
+ boxWidth: s * chosen.box.width,
1022
+ boxHeight: s * chosen.box.height,
1023
+ vertical: chosen.vertical,
1024
+ subject,
1025
+ straddleDx: found.dx
1026
+ });
1027
+ }
1028
+ return out;
1029
+ }
1030
+
1031
+ /** Set-wide look shared by every layer builder. */
1032
+ interface Look {
1033
+ shadows: boolean;
1034
+ /** rgba() shadow colour (a deep tint of the brand hue for tonal palettes). */
1035
+ shadowColor: string;
1036
+ }
1037
+
1038
+ function shadowFor(W: number, scale: number, look: Look, spec: { blur: number; offsetY: number } = SHADOW) {
1039
+ // Fabric scales shadow blur/offset by the object's scale (the editor's shadow controls use the
1040
+ // same convention), so express them in the image's own units.
1041
+ return { color: look.shadowColor, blur: (spec.blur * W) / scale, offsetX: 0, offsetY: (spec.offsetY * W) / scale };
1042
+ }
1043
+
1044
+ /** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
1045
+ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look): LayerJSON {
1046
+ const id = generateLayerId();
1047
+ const shot = r.plan.screenshot;
1048
+ const zoom = p.zoom;
1049
+ const width = zoom ? zoom.cropW : shot.width;
1050
+ const height = zoom ? zoom.cropH : shot.height;
1051
+ const radius = zoom ? ZOOM_RADIUS * r.W : FRAMELESS_RADIUS * width * p.scale;
1052
+ const fabricData: Record<string, unknown> = {
1053
+ type: 'image',
1054
+ src: shot.url,
1055
+ crossOrigin: 'anonymous',
1056
+ left: p.cx,
1057
+ top: p.cy,
1058
+ width,
1059
+ height,
1060
+ scaleX: p.scale,
1061
+ scaleY: p.scale,
1062
+ originX: 'center',
1063
+ originY: 'center',
1064
+ // Rounded corners in the image's local (unscaled) space — same shape the editor's corner
1065
+ // radius control writes (`imageCornerRadius` is in canvas units).
1066
+ clipPath: {
1067
+ type: 'Rect',
1068
+ left: 0,
1069
+ top: 0,
1070
+ width,
1071
+ height,
1072
+ rx: radius / p.scale,
1073
+ ry: radius / p.scale,
1074
+ originX: 'center',
1075
+ originY: 'center'
1076
+ },
1077
+ imageCornerRadius: radius,
1078
+ layerId: id,
1079
+ layerType: 'image'
1080
+ };
1081
+ if (look.shadows) fabricData.shadow = shadowFor(r.W, p.scale, look);
1082
+ if (zoom) {
1083
+ fabricData.cropX = zoom.cropX;
1084
+ fabricData.cropY = zoom.cropY;
1085
+ }
1086
+ if (p.angle) fabricData.angle = p.angle;
1087
+ return {
1088
+ id,
1089
+ name: zoom ? 'Screenshot (zoom)' : 'Screenshot',
1090
+ type: 'image',
1091
+ visible: true,
1092
+ locked: false,
1093
+ fabricData
1094
+ };
1095
+ }
1096
+
1097
+ function makeSubjectLayer(r: ResolvedScreen, p: Placement, look: Look, cx = p.cx, name?: string): LayerJSON {
1098
+ if (r.presentation === 'device') {
1099
+ // One device layer; the screenshot rides on it as `fabricData.screenshot` and the editor
1100
+ // places + clips it under the frame on import (see DeviceScreenshotJSON).
1101
+ const layer = makeDeviceFrameLayer({
1102
+ deviceId: r.plan.deviceId,
1103
+ screenshotUrl: r.plan.screenshot.url,
1104
+ screenshotWidth: r.plan.screenshot.width,
1105
+ screenshotHeight: r.plan.screenshot.height,
1106
+ canvasWidth: r.W,
1107
+ canvasHeight: r.H,
1108
+ centerX: cx,
1109
+ centerY: p.cy,
1110
+ scale: p.scale,
1111
+ angle: p.angle,
1112
+ name
1113
+ });
1114
+ // 0.5.0: the frame's drop shadow, in CANVAS units. The editor casts it from the screenshot
1115
+ // (clipped to the screen shape) rather than from the frame PNG, whose transparent screen hole
1116
+ // would otherwise let the bezel's shadow fall onto the screen. Older editors ignore it.
1117
+ if (look.shadows) {
1118
+ (layer.fabricData as Record<string, unknown>).deviceShadow = {
1119
+ color: look.shadowColor,
1120
+ blur: DEVICE_SHADOW.blur * r.W,
1121
+ offsetX: 0,
1122
+ offsetY: DEVICE_SHADOW.offsetY * r.W
1123
+ };
1124
+ }
1125
+ return layer;
1126
+ }
1127
+ const layer = makeScreenshotImageLayer(r, p, look);
1128
+ (layer.fabricData as Record<string, unknown>).left = cx;
1129
+ if (name) layer.name = name;
1130
+ return layer;
1131
+ }
1132
+
1133
+ /** Canvas corners of a screen's focus band for a given pose (null when no focus is marked). */
1134
+ function focusPolygon(r: ResolvedScreen, p: Placement, cx: number) {
1135
+ const focus = normalizeFocus(r.plan.focus);
1136
+ if (!focus || p.zoom) return null;
1137
+ return focusCorners(p.subject, focus).map((pt) => subjectPointToCanvas(p.subject, pt, { cx, cy: p.cy, scale: p.scale, angle: p.angle }));
1138
+ }
1139
+
1140
+ interface TextRects {
1141
+ badge?: Rect;
1142
+ headline: Rect;
1143
+ /** One box per headline line (estimated width — conservative, i.e. never narrower than rendered). */
1144
+ lines: Rect[];
1145
+ sub?: Rect;
1146
+ }
1147
+
1148
+ const textRectList = (t: TextRects): Rect[] => [t.badge, t.headline, t.sub].filter((x): x is Rect => !!x);
1149
+
1150
+ function validateSpans(plan: ComposePlan, dims: Array<{ W: number; H: number }>): number[][] {
1151
+ const spans = plan.style?.panorama?.spans ?? [];
1152
+ const seen = new Set<number>();
1153
+ for (const span of spans) {
1154
+ if (!Array.isArray(span) || span.length < 2) throw new Error('panorama span must list at least 2 screens');
1155
+ span.forEach((idx, k) => {
1156
+ if (!Number.isInteger(idx) || idx < 0 || idx >= plan.screens.length) throw new Error(`panorama span index ${idx} is out of range`);
1157
+ if (k > 0 && idx !== span[k - 1] + 1) throw new Error('panorama span screens must be adjacent and ascending');
1158
+ if (seen.has(idx)) throw new Error(`screen ${idx} is in more than one panorama span`);
1159
+ seen.add(idx);
1160
+ if (dims[idx].W !== dims[span[0]].W || dims[idx].H !== dims[span[0]].H) {
1161
+ throw new Error('panorama span screens must share one canvas size');
1162
+ }
1163
+ });
1164
+ }
1165
+ const straddle = plan.style?.panorama?.straddle;
1166
+ if (Array.isArray(straddle)) {
1167
+ for (const idx of straddle) {
1168
+ if (!spans.some((span) => span[0] === idx)) throw new Error(`panorama straddle ${idx} is not the first screen of a span`);
1169
+ }
1170
+ }
1171
+ return spans;
1172
+ }
1173
+
1174
+ function normalizeCrop(c: ComposeCrop | undefined | false): ComposeCrop | undefined {
1175
+ if (!c || typeof c !== 'object') return undefined;
1176
+ const x = clamp01(c.x);
1177
+ const y = clamp01(c.y);
1178
+ const w = Math.min(1 - x, clamp01(c.w));
1179
+ const h = Math.min(1 - y, clamp01(c.h));
1180
+ return [c.x, c.y, c.w, c.h].every(Number.isFinite) && w > 0 && h > 0 ? { x, y, w, h } : undefined;
1181
+ }
1182
+
1183
+ /**
1184
+ * Auto callout (`style.callouts: "auto"`): a left-aligned slice at the top of the focus band — only on
1185
+ * "selling" screens, i.e. where the band is tight (≤ CALLOUT_AUTO_MAX_FOCUS); a loose band says the
1186
+ * whole screen sells, and magnifying a blind slice of it adds noise. `force` (a `callout` accent)
1187
+ * skips that test.
1188
+ */
1189
+ function autoCallout(screen: ComposeScreenPlan, force = false): ComposeCrop | undefined {
1190
+ const focus = normalizeFocus(screen.focus);
1191
+ if (!focus || (!force && focus.bottom - focus.top > CALLOUT_AUTO_MAX_FOCUS + 1e-9)) return undefined;
1192
+ const { width: sw, height: sh } = screen.screenshot;
1193
+ const h = Math.min(focus.bottom - focus.top, (CALLOUT_AUTO.w * sw * CALLOUT_AUTO.aspect) / sh);
1194
+ const y = Math.min(1 - h, focus.top + (focus.bottom - focus.top - h) * 0.15);
1195
+ return { x: CALLOUT_AUTO.x, y, w: CALLOUT_AUTO.w, h };
1196
+ }
1197
+
1198
+ const HEX_TEXT_FALLBACK = '#ffffff';
1199
+
1200
+ /**
1201
+ * Two-pass SET layout: measure every text block → one type size + a text area reserved for the
1202
+ * tallest block (per canvas size) → one subject scale/baseline per group (canvas, presentation,
1203
+ * subject, layout, tilt, role) → the no-tangent bleed rule (respecting `focus`) → per-screen layers.
1204
+ * Copy length never moves or resizes anything; copy-rule violations come back as `report.warnings`.
1205
+ *
1206
+ * 0.5.0 art direction on top of that system: balanced line breaks, a hero screen (larger type,
1207
+ * deeper bleed, optional mascot + badge), an accent rhythm, a tonal brand palette with ONE text
1208
+ * colour per set (WCAG ≥ 4.5 everywhere), magnified callouts, mascots, shadows and continuous
1209
+ * panorama motifs. Non-accent screens still share one type size, text area, scale and baseline.
1210
+ */
1211
+ export function composeSet(plan: ComposePlan): { template: Template; report: ComposeReport } {
1212
+ const style = plan.style ?? {};
1213
+ const warnings: ComposeWarning[] = [];
1214
+ const bleedPref: BleedPreference = (COMPOSE_BLEEDS as readonly string[]).includes(style.bleed ?? '') ? style.bleed! : 'auto';
1215
+ const font = style.font && COMPOSE_FONTS.includes(style.font) ? style.font : 'Inter';
1216
+ const tiltScreens = new Set(style.tiltScreens ?? []);
1217
+ const n = plan.screens.length;
1218
+
1219
+ // Per-screen canvas dims: honor explicit plan-level dims (backward compatible), else derive from
1220
+ // the screen's device class so a mixed-device plan gets the correct aspect per screen.
1221
+ const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
1222
+ const dims = plan.screens.map((screen) => {
1223
+ if (!getDeviceFrame(screen.deviceId)) throw new Error(`Unknown device: ${screen.deviceId}`);
1224
+ const { width, height } = explicit
163
1225
  ? { width: plan.canvasWidth ?? 280, height: plan.canvasHeight ?? 600 }
164
1226
  : canvasDimsForDevice(screen.deviceId);
1227
+ return { W: width, H: height };
1228
+ });
1229
+ const spans = validateSpans(plan, dims);
1230
+ const spanOf = new Map<number, { span: number[]; k: number }>();
1231
+ for (const span of spans) span.forEach((idx, k) => spanOf.set(idx, { span, k }));
1232
+
1233
+ // Brand art (mascots): uploaded-asset URLs only, like screenshots.
1234
+ const art = new Map<string, ComposeArt>();
1235
+ for (const a of plan.art ?? []) {
1236
+ if (!a || typeof a.id !== 'string' || !a.id) throw new Error('art entries need an id');
1237
+ if (!isUploadedScreenshotSrc(a.url)) throw new Error(`art "${a.id}": url must be an uploaded asset (/api/screenshots/<id>/raw)`);
1238
+ if (!(a.width > 0 && a.height > 0)) throw new Error(`art "${a.id}": width/height must be positive`);
1239
+ art.set(a.id, a);
1240
+ }
165
1241
 
166
- const layout: ComposeLayout = screen.layout ?? 'text-top';
167
- const spec = LAYOUTS[layout] ?? LAYOUTS['text-top'];
1242
+ // Roles: the hero (default screen 1) and the accent rhythm.
1243
+ const heroCfg: ComposeHero | null = style.hero === false ? null : style.hero && typeof style.hero === 'object' ? style.hero : {};
1244
+ const heroIndex = heroCfg && n > 0 ? Math.min(n - 1, Math.max(0, Math.floor(Number.isFinite(heroCfg.screen) ? heroCfg.screen! : 0))) : -1;
1245
+ const rhythm =
1246
+ style.rhythm && Number.isFinite(style.rhythm.every)
1247
+ ? { every: Math.min(6, Math.max(3, Math.round(style.rhythm.every))), treatment: style.rhythm.treatment === 'callout' ? 'callout' : 'text-bottom' }
1248
+ : null;
1249
+ const roleOf = (i: number): ScreenRole => (i === heroIndex ? 'hero' : rhythm && i % rhythm.every === rhythm.every - 1 ? 'accent' : 'set');
1250
+ const heroBadge = heroCfg?.badge?.trim() || undefined;
1251
+ const badgeOf = (i: number) => (i === heroIndex ? (heroBadge ?? plan.screens[i].badge?.trim()) : plan.screens[i].badge?.trim()) || undefined;
1252
+
1253
+ // Pass 1: set typography per canvas size (tallest text block wins); the hero gets its own scale.
1254
+ const typoByKey = new Map<string, Typography>();
1255
+ const dimsKey = (d: { W: number; H: number }) => `${d.W}x${d.H}`;
1256
+ const makeTypo = (i: number, scale: number, hasBadge: boolean): Typography => {
1257
+ const { W, H } = dims[i];
168
1258
  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
1259
+ const headlineSize = unit * HEADLINE_SIZE * scale;
1260
+ const badgeFont = unit * HEADLINE_SIZE * BADGE_FONT;
1261
+ return {
1262
+ W,
1263
+ H,
1264
+ unit,
1265
+ headlineSize,
1266
+ subSize: headlineSize * SUBHEADLINE_RATIO,
1267
+ textWidth: W * TEXT_WIDTH,
1268
+ margin: H * EDGE_MARGIN,
1269
+ deviceGap: unit * DEVICE_GAP,
1270
+ badgeFont,
1271
+ font,
1272
+ badgeRow: hasBadge ? badgeFont * BADGE_HEIGHT + unit * HEADLINE_SIZE * BADGE_GAP : 0,
1273
+ textArea: 0
1274
+ };
1275
+ };
1276
+ const typoFor = (i: number): Typography => {
1277
+ const hero = i === heroIndex;
1278
+ const key = `${dimsKey(dims[i])}|${hero ? 'hero' : 'set'}`;
1279
+ const cached = typoByKey.get(key);
1280
+ if (cached) return cached;
1281
+ let typo: Typography;
1282
+ if (hero) {
1283
+ // Hero scale: as large as asked, but it never adds a headline line (nor makes a word wider
1284
+ // than the box) — it steps down in 0.05s to 1× instead.
1285
+ const want = Math.min(HERO_SCALE_RANGE[1], Math.max(HERO_SCALE_RANGE[0], Number.isFinite(heroCfg?.scale) ? heroCfg!.scale! : HERO_SCALE));
1286
+ const hasBadge = !!badgeOf(i);
1287
+ const base = measureTextBlock(plan.screens[i], makeTypo(i, 1, hasBadge)).headlineLines;
1288
+ typo = makeTypo(i, 1, hasBadge);
1289
+ for (let s = want; s >= 1 - 1e-9; s -= 0.05) {
1290
+ const t = makeTypo(i, s, hasBadge);
1291
+ const fits = overlongWords(plan.screens[i].headline, t.textWidth, (w) => estimateTextWidth(w, t.headlineSize, t.font)).length === 0;
1292
+ if (fits && measureTextBlock(plan.screens[i], t).headlineLines <= base) {
1293
+ typo = t;
1294
+ break;
1295
+ }
1296
+ }
188
1297
  } 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
1298
+ const hasBadge = plan.screens.some((_, j) => j !== heroIndex && dimsKey(dims[j]) === dimsKey(dims[i]) && !!badgeOf(j));
1299
+ typo = makeTypo(i, 1, hasBadge);
192
1300
  }
1301
+ typoByKey.set(key, typo);
1302
+ return typo;
1303
+ };
193
1304
 
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,
201
- canvasWidth: W,
202
- canvasHeight: H,
203
- centerX: W / 2,
204
- centerY,
205
- scale
206
- });
1305
+ const calloutsAuto = style.callouts === 'auto';
1306
+ const resolved: ResolvedScreen[] = plan.screens.map((screen, i) => {
1307
+ const role = roleOf(i);
1308
+ const typo = typoFor(i);
1309
+ // Fabric never breaks inside a word: it widens the Textbox past its margins. Reject instead.
1310
+ for (const [what, text, size] of [
1311
+ ['headline', screen.headline, typo.headlineSize],
1312
+ ['subheadline', screen.subheadline ?? '', typo.subSize]
1313
+ ] as const) {
1314
+ const long = overlongWords(text, typo.textWidth, (w) => estimateTextWidth(w, size, typo.font));
1315
+ if (long.length) {
1316
+ throw new Error(
1317
+ `screen ${i + 1}: the ${what} word "${long[0]}" is wider than the text box at the set size (it can't wrap inside a word) — shorten or split it`
1318
+ );
1319
+ }
1320
+ }
1321
+ const block = measureTextBlock(screen, typo);
1322
+ typo.textArea = Math.max(typo.textArea, block.height);
1323
+ const presentation: ComposePresentation = (COMPOSE_PRESENTATIONS as readonly string[]).includes(screen.presentation ?? '')
1324
+ ? screen.presentation!
1325
+ : (COMPOSE_PRESENTATIONS as readonly string[]).includes(style.presentation ?? '')
1326
+ ? style.presentation!
1327
+ : 'device';
1328
+ let layout: ComposeLayout = screen.layout && LAYOUTS[screen.layout] ? screen.layout : 'text-top';
1329
+ let bleed: BleedPreference = bleedPref;
1330
+ let rawTilt = typeof screen.tilt === 'number' ? screen.tilt : tiltScreens.has(i) ? (style.tilt ?? 0) : 0;
1331
+ if (role === 'hero' && heroCfg) {
1332
+ if (heroCfg.layout && LAYOUTS[heroCfg.layout]) layout = heroCfg.layout;
1333
+ bleed = (COMPOSE_BLEEDS as readonly string[]).includes(heroCfg.bleed ?? '') ? heroCfg.bleed! : bleedPref === 'none' ? 'none' : 'deep';
1334
+ if (typeof heroCfg.tilt === 'number') rawTilt = heroCfg.tilt;
1335
+ } else if (role === 'accent' && rhythm) {
1336
+ if (rhythm.treatment === 'text-bottom') layout = 'text-bottom';
1337
+ else if (bleedPref !== 'none') bleed = 'deep';
1338
+ }
1339
+ if (presentation === 'zoom' && layout === 'device-bleed') layout = 'text-top';
1340
+ const tilt = Number.isFinite(rawTilt) ? Math.max(-30, Math.min(30, rawTilt)) : 0;
1341
+ const fromPalette = screen.background ? null : paletteBackground(style.palette, i, role === 'hero' ? 0 : i);
1342
+ const background = screen.background ?? fromPalette ?? { type: 'solid', color: '#1F2937' };
1343
+ const backgroundFromStyle = !screen.background;
1344
+ const subjectKey =
1345
+ presentation === 'device'
1346
+ ? screen.deviceId
1347
+ : presentation === 'frameless'
1348
+ ? `shot:${screen.screenshot.width}x${screen.screenshot.height}`
1349
+ : 'card';
1350
+ const group = `${dims[i].W}x${dims[i].H}|${presentation}|${subjectKey}|${layout}|tilt:${tilt}|${role}|bleed:${bleed}`;
1351
+ // Callout: explicit crop, else auto (style.callouts / a `callout` accent) from the focus band.
1352
+ const explicitCallout = normalizeCrop(screen.callout);
1353
+ const accentCallout = role === 'accent' && rhythm?.treatment === 'callout';
1354
+ const wantsAuto = screen.callout !== false && !explicitCallout && (accentCallout || (calloutsAuto && role !== 'hero'));
1355
+ const calloutReq = presentation === 'zoom' ? undefined : (explicitCallout ?? (wantsAuto ? autoCallout(screen, accentCallout) : undefined));
1356
+ const mascot = (role === 'hero' ? (heroCfg?.mascot ?? screen.mascot) : screen.mascot) || undefined;
1357
+ if (mascot && !art.has(mascot.art)) throw new Error(`screen ${i + 1}: mascot.art "${mascot.art}" is not in plan.art`);
1358
+ return {
1359
+ index: i,
1360
+ plan: screen,
1361
+ W: dims[i].W,
1362
+ H: dims[i].H,
1363
+ layout,
1364
+ presentation,
1365
+ tilt,
1366
+ background,
1367
+ backgroundFromStyle,
1368
+ typo,
1369
+ block,
1370
+ group,
1371
+ role,
1372
+ bleed,
1373
+ badge: badgeOf(i),
1374
+ calloutReq,
1375
+ mascot
1376
+ };
1377
+ });
207
1378
 
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'
1379
+ // Requested straddlers: the first screen of a span, per `panorama.straddle` (never zoom cards).
1380
+ // They get their own group (their pose is judged after the seam shift).
1381
+ const straddleSpec = style.panorama?.straddle;
1382
+ const wantsStraddle = new Set(
1383
+ straddleSpec
1384
+ ? spans.map((span) => span[0]).filter((i) => (Array.isArray(straddleSpec) ? straddleSpec.includes(i) : true) && resolved[i].presentation !== 'zoom')
1385
+ : []
1386
+ );
1387
+ for (const i of wantsStraddle) resolved[i].group += '|straddle';
1388
+
1389
+ // Pass 2: one pose per group.
1390
+ const groups = new Map<string, ResolvedScreen[]>();
1391
+ for (const r of resolved) groups.set(r.group, [...(groups.get(r.group) ?? []), r]);
1392
+ const placements = new Map<number, Placement>();
1393
+ for (const members of groups.values()) {
1394
+ const asStraddle = members.every((m) => wantsStraddle.has(m.index));
1395
+ for (const [i, p] of placeGroup(members, members[0].bleed, warnings, asStraddle)) placements.set(i, p);
1396
+ }
1397
+
1398
+ // Panorama: straddling devices (first screen of a span) cross into the next screen.
1399
+ const straddle = new Map<number, { cx: number; into: number }>();
1400
+ if (wantsStraddle.size) {
1401
+ for (const span of spans) {
1402
+ const i = span[0];
1403
+ if (!wantsStraddle.has(i)) continue;
1404
+ const r = resolved[i];
1405
+ const p = placements.get(i)!;
1406
+ if (r.presentation === 'zoom') continue;
1407
+ const W = r.W;
1408
+ // placeGroup found (or failed to find) a pose that straddles within the side / seam rules.
1409
+ const dx = p.straddleDx;
1410
+ const focus = normalizeFocus(r.plan.focus);
1411
+ if (dx == null) {
1412
+ warnings.push({
1413
+ screen: i,
1414
+ code: 'straddle-skipped',
1415
+ message: `screen ${i + 1}: device kept inside its screen — crossing the seam decisively (≥ ${Math.round(STRADDLE_MIN * 100)}% on the next screen) would put its focus band across it (or break the side margin)`
1416
+ });
1417
+ continue;
1418
+ }
1419
+ if (!focus) {
1420
+ warnings.push({
1421
+ screen: i,
1422
+ code: 'panorama-seam',
1423
+ 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}`
1424
+ });
1425
+ }
1426
+ straddle.set(i, { cx: W / 2 + dx, into: span[1] });
1427
+ }
1428
+ }
1429
+
1430
+ // ---- Palette: ONE text colour per set (B2), WCAG ≥ 4.5 on every style-derived background. ----
1431
+ const paletteColors = (style.palette?.colors ?? []).filter(isHexColor);
1432
+ const tonal = style.palette?.mode === 'tonal' && paletteColors.length > 0;
1433
+ const brandBase = tonal ? paletteColors[0] : null;
1434
+ const accentColor = tonal ? (paletteColors[1] ?? null) : null;
1435
+ const candidates = textCandidates(brandBase);
1436
+
1437
+ /** What a panorama span paints, given each screen's (possibly shifted) background. */
1438
+ const spanFill = (span: number[], bgOf: (r: ResolvedScreen) => BackgroundJSON): SpanFill => {
1439
+ const r0 = resolved[span[0]];
1440
+ const N = span.length;
1441
+ if (tonal && span.every((i) => resolved[i].backgroundFromStyle)) {
1442
+ // Tonal scene: one flowing ramp through every member's tone, corner to corner.
1443
+ const stops = span.map((i, k) => ({ offset: N > 1 ? k / (N - 1) : 0, color: sampleBackground(bgOf(resolved[i]), r0.W, r0.H, r0.W / 2, r0.H / 2) }));
1444
+ return { kind: 'linear', stops, coords: { x1: 0, y1: r0.H, x2: N * r0.W, y2: 0 } };
1445
+ }
1446
+ return spanFillFor(bgOf(r0), span[0], N, r0.W, r0.H);
1447
+ };
1448
+ const samplesOf = (r: ResolvedScreen, bgOf: (r: ResolvedScreen) => BackgroundJSON): string[] => {
1449
+ const out: string[] = [];
1450
+ const inSpan = spanOf.get(r.index);
1451
+ const fill = inSpan ? spanFill(inSpan.span, bgOf) : null;
1452
+ const bg = bgOf(r);
1453
+ for (let gx = 0; gx <= 4; gx++) {
1454
+ for (let gy = 0; gy <= 6; gy++) {
1455
+ const x = (gx / 4) * r.W;
1456
+ const y = (gy / 6) * r.H;
1457
+ out.push(fill ? samplePanorama(fill, inSpan!.k, r.W, x, y) : sampleBackground(bg, r.W, r.H, x, y));
1458
+ }
1459
+ }
1460
+ return out;
1461
+ };
1462
+ // M1: EVERY screen without an explicit headlineColor takes part in choosing the set colour.
1463
+ // Palette backgrounds (and spans whose fill comes from the palette) may be re-toned until it
1464
+ // passes; explicit backgrounds — including a panorama span whose fill is explicit — are sampled as
1465
+ // they are (never recoloured). When the set colour can't reach AA on such a FIXED unit, that unit
1466
+ // gets its own readable colour: a lone screen, or a whole span (one colour per span, so the scene
1467
+ // stays consistent) — metrics `textColorSource: "screen"`.
1468
+ const needsAuto = (r: ResolvedScreen) => !r.plan.headlineColor;
1469
+ /** Is this screen's visible background re-tonable (palette-derived)? A span follows its fill's source. */
1470
+ const shiftable = (r: ResolvedScreen) => {
1471
+ const inSpan = spanOf.get(r.index);
1472
+ return inSpan ? resolved[inSpan.span[0]].backgroundFromStyle : r.backgroundFromStyle;
1473
+ };
1474
+ const autoScreens = resolved.filter((r) => needsAuto(r) && shiftable(r));
1475
+ /** Fixed units: each explicit lone screen, and each span with an explicit fill (as one unit). */
1476
+ const fixedUnits: ResolvedScreen[][] = [];
1477
+ for (const r of resolved) {
1478
+ if (!needsAuto(r) || shiftable(r)) continue;
1479
+ const inSpan = spanOf.get(r.index);
1480
+ const unit = inSpan ? fixedUnits.find((u) => spanOf.get(u[0].index)?.span === inSpan.span) : undefined;
1481
+ if (unit) unit.push(r);
1482
+ else fixedUnits.push([r]);
1483
+ }
1484
+ const fixedSamples = fixedUnits.flat().flatMap((r) => samplesOf(r, (x) => x.background));
1485
+ let setText = HEX_TEXT_FALLBACK;
1486
+ if (autoScreens.length || fixedUnits.length) {
1487
+ const shifted = (target: string, shift: number) => (r: ResolvedScreen) => (r.backgroundFromStyle ? shiftBackground(r.background, target, shift) : r.background);
1488
+ // An explicit-only set keeps 0.4.0's white whenever white works (preferLight).
1489
+ const h = harmonize(
1490
+ autoScreens.length ? candidates : { dark: DARK_TEXT_FALLBACK, light: HEX_TEXT_FALLBACK },
1491
+ (target, shift) => autoScreens.flatMap((r) => samplesOf(r, shifted(target, shift))),
1492
+ fixedSamples,
1493
+ !autoScreens.length
1494
+ );
1495
+ setText = h.text;
1496
+ const target = h.text === candidates.dark ? '#FFFFFF' : '#000000';
1497
+ for (const r of resolved) if (r.backgroundFromStyle) r.background = shiftBackground(r.background, target, h.shift);
1498
+ }
1499
+ const bgNow = (r: ResolvedScreen) => r.background;
1500
+ /** Fixed units where the set colour can't reach AA: their own best colour (one per unit). */
1501
+ const ownText = new Map<number, string>();
1502
+ for (const unit of fixedUnits) {
1503
+ const samples = unit.flatMap((r) => samplesOf(r, bgNow));
1504
+ if (worstContrast(setText, samples) >= MIN_CONTRAST) continue;
1505
+ const options = [setText, candidates.dark, candidates.light, HEX_TEXT_FALLBACK, DARK_TEXT_FALLBACK].filter(isHexColor);
1506
+ const best = options.sort((a, b) => worstContrast(b, samples) - worstContrast(a, samples))[0];
1507
+ for (const r of unit) ownText.set(r.index, best);
1508
+ }
1509
+ const textColorOf = (r: ResolvedScreen) => r.plan.headlineColor ?? ownText.get(r.index) ?? setText;
1510
+ const textSourceOf = (r: ResolvedScreen): 'set' | 'screen' | 'plan' => (r.plan.headlineColor ? 'plan' : ownText.has(r.index) ? 'screen' : 'set');
1511
+ const contrastOf = new Map<number, number>();
1512
+ const warnedContrast = new Set<number>();
1513
+ for (const r of resolved) {
1514
+ const worst = worstContrast(textColorOf(r), samplesOf(r, bgNow));
1515
+ contrastOf.set(r.index, worst);
1516
+ if (!r.plan.headlineColor && worst < MIN_CONTRAST) {
1517
+ warnedContrast.add(r.index);
1518
+ warnings.push({
1519
+ screen: r.index,
1520
+ code: 'contrast-low',
1521
+ message: `screen ${r.index + 1}: text contrast is ${worst.toFixed(2)}:1 on its background (WCAG AA needs 4.5) — use style.palette or a darker/lighter background`
1522
+ });
1523
+ }
1524
+ }
1525
+
1526
+ // The set's look: shadows tinted with the brand hue.
1527
+ const look: Look = {
1528
+ shadows: style.shadows !== false,
1529
+ shadowColor: brandBase
1530
+ ? rgba(hslToHex({ h: hexToHsl(brandBase).h, s: Math.min(0.8, hexToHsl(brandBase).s), l: 0.14 }), 0.34)
1531
+ : SHADOW.color
1532
+ };
1533
+
1534
+ // Span fills (validated up front, so a bad span background fails before any layer is built).
1535
+ const spanFills = new Map<number, SpanFill>();
1536
+ for (const span of spans) spanFills.set(span[0], spanFill(span, bgNow));
1537
+ /** Vertical band of a screen's reserved text block (with a small breathing gap). */
1538
+ const textBand = (r: ResolvedScreen) => {
1539
+ const top = r.layout === 'text-bottom' ? r.H - r.typo.margin - r.typo.textArea : r.typo.margin;
1540
+ const gap = ORB_TEXT_GAP * r.H;
1541
+ return { top: top - gap, bottom: top + r.typo.textArea + gap };
1542
+ };
1543
+ /**
1544
+ * The orb on seam `seam` of a span: centred on ORB_Y·H with ORB_RADIUS·W, but moved/shrunk so it
1545
+ * never overlaps either neighbour's text block (small landscape canvases); null when there's no room.
1546
+ */
1547
+ const orbFor = (span: number[], seam: number): { cy: number; r: number } | null => {
1548
+ const a = resolved[span[seam - 1]];
1549
+ const b = resolved[span[seam]];
1550
+ const H = a.H;
1551
+ const blocks = [textBand(a), textBand(b)].sort((x, y) => x.top - y.top);
1552
+ const free: Array<{ lo: number; hi: number }> = [];
1553
+ let cursor = 0;
1554
+ for (const t of blocks) {
1555
+ if (t.top > cursor) free.push({ lo: cursor, hi: t.top });
1556
+ cursor = Math.max(cursor, t.bottom);
1557
+ }
1558
+ if (cursor < H) free.push({ lo: cursor, hi: H });
1559
+ const want = ORB_Y * H;
1560
+ const pick = free.find((f) => f.lo <= want && want <= f.hi) ?? free.sort((x, y) => y.hi - y.lo - (x.hi - x.lo))[0];
1561
+ if (!pick) return null;
1562
+ // The orb may run off the canvas edge (it's decoration), but never into a text block.
1563
+ const lo = pick.lo === 0 ? -Infinity : pick.lo;
1564
+ const hi = pick.hi === H ? Infinity : pick.hi;
1565
+ const r = Math.min(ORB_RADIUS * a.W, (hi - lo) / 2);
1566
+ if (!(r >= ORB_MIN_RADIUS * a.W)) return null;
1567
+ const cy = Math.min(Math.max(want, lo + r), hi - r);
1568
+ return { cy, r };
1569
+ };
1570
+
1571
+ // ---- Pass 3a: per-screen geometry (text boxes, callouts) — needed by neighbours' mascots. ----
1572
+ interface Geo {
1573
+ r: ResolvedScreen;
1574
+ p: Placement;
1575
+ cx: number;
1576
+ areaTop: number;
1577
+ headlineTop: number;
1578
+ rects: TextRects;
1579
+ text: string;
1580
+ subText?: string;
1581
+ callout?: { rect: Rect; mag: number; crop: { x: number; y: number; w: number; h: number }; scale: number; focusCover: number };
1582
+ focusCore?: Rect;
1583
+ mascot?: { rect: Rect; scale: number; flip: boolean; art: ComposeArt; seamPartner?: number; dx?: number };
1584
+ }
1585
+ const geos: Geo[] = resolved.map((r) => {
1586
+ const { W, H, typo, block } = r;
1587
+ const p = placements.get(r.index)!;
1588
+ const cx = straddle.get(r.index)?.cx ?? p.cx;
1589
+ const areaTop = r.layout === 'text-bottom' ? H - typo.margin - typo.textArea : typo.margin;
1590
+ const headlineTop = areaTop + typo.badgeRow;
1591
+ const lh = typo.headlineSize * HEADLINE_LINE_HEIGHT;
1592
+ const rects: TextRects = {
1593
+ headline: { left: (W - typo.textWidth) / 2, top: headlineTop, right: (W + typo.textWidth) / 2, bottom: headlineTop + block.headlineHeight },
1594
+ lines: block.headline.map((line, k) => {
1595
+ const w = Math.min(typo.textWidth, estimateTextWidth(line, typo.headlineSize, typo.font));
1596
+ return { left: (W - w) / 2, top: headlineTop + k * lh, right: (W + w) / 2, bottom: headlineTop + (k + 1) * lh };
1597
+ })
1598
+ };
1599
+ if (r.badge) {
1600
+ const pillH = typo.badgeFont * BADGE_HEIGHT;
1601
+ const pillW = Math.min(typo.textWidth, estimateTextWidth(r.badge, typo.badgeFont, typo.font) + 2 * BADGE_PAD_X * typo.badgeFont);
1602
+ rects.badge = { left: W / 2 - pillW / 2, top: areaTop, right: W / 2 + pillW / 2, bottom: areaTop + pillH };
1603
+ }
1604
+ if (block.sub.length) {
1605
+ const subTop = headlineTop + block.headlineHeight + block.gap;
1606
+ rects.sub = { left: rects.headline.left, top: subTop, right: rects.headline.right, bottom: subTop + block.subHeight };
1607
+ }
1608
+ const poly = focusPolygon(r, p, cx);
1609
+ let focusCore: Rect | undefined;
1610
+ if (poly) {
1611
+ const xs = poly.map((pt) => pt.x);
1612
+ const ys = poly.map((pt) => pt.y);
1613
+ const inset = MASCOT_FOCUS_INSET * W;
1614
+ focusCore = { left: Math.min(...xs) + inset, right: Math.max(...xs) - inset, top: Math.min(...ys), bottom: Math.max(...ys) };
1615
+ }
1616
+ const geo: Geo = { r, p, cx, areaTop, headlineTop, rects, text: block.headline.join('\n'), subText: block.sub.length ? block.sub.join('\n') : undefined, focusCore };
1617
+ // Callouts never ride a seam-crossing device.
1618
+ if (r.calloutReq && !straddle.has(r.index)) geo.callout = calloutGeometry(r, p, cx, rects, warnings) ?? undefined;
1619
+ return geo;
1620
+ });
1621
+
1622
+ // ---- Pass 3b: mascots (placement avoids text, callouts and the focus band; may cross a seam). ----
1623
+ /** Mascots already placed that show on screen `idx` (its own, and seam halves crossing into it). */
1624
+ const mascotsOn = (idx: number): Rect[] =>
1625
+ geos.flatMap((o) => {
1626
+ if (!o.mascot) return [];
1627
+ if (o.r.index === idx) return [o.mascot.rect];
1628
+ if (o.mascot.seamPartner === idx) return [{ ...o.mascot.rect, left: o.mascot.rect.left + o.mascot.dx!, right: o.mascot.rect.right + o.mascot.dx! }];
1629
+ return [];
223
1630
  });
1631
+ const obstaclesOf = (g: Geo): Rect[] => [
1632
+ ...textRectList(g.rects).filter((x) => x !== g.rects.headline),
1633
+ ...g.rects.lines,
1634
+ ...(g.callout ? [g.callout.rect] : []),
1635
+ // Low (review): mascots never overlap each other, including a neighbour's seam half.
1636
+ ...mascotsOn(g.r.index)
1637
+ ];
1638
+ for (const g of geos) {
1639
+ const m = g.r.mascot;
1640
+ if (!m) continue;
1641
+ const a = art.get(m.art)!;
1642
+ const { W, H } = g.r;
1643
+ const inSpan = spanOf.get(g.r.index);
1644
+ const partner = inSpan ? (inSpan.k < inSpan.span.length - 1 ? inSpan.span[inSpan.k + 1] : inSpan.span[inSpan.k - 1]) : undefined;
1645
+ const seamX = inSpan ? (inSpan.k < inSpan.span.length - 1 ? W : 0) : undefined;
1646
+ const size = (Number.isFinite(m.size) && m.size! > 0 ? Math.min(0.5, m.size!) : g.r.role === 'hero' ? MASCOT_SIZE.hero : MASCOT_SIZE.screen) * W;
1647
+ const anchor: MascotAnchor = (MASCOT_ANCHORS as readonly string[]).includes(m.anchor ?? '') ? m.anchor! : 'headline';
1648
+ const order: MascotAnchor[] = [anchor, ...(['headline', 'device-top', 'device-side'] as MascotAnchor[]).filter((x) => x !== anchor)];
1649
+ const pad = MASCOT_PAD * W;
1650
+ const margin = MASCOT_MARGIN * W;
1651
+ const textRects = textRectList(g.rects);
1652
+ const textTop = Math.min(...textRects.map((t) => t.top));
1653
+ const textBottom = Math.max(...textRects.map((t) => t.bottom));
1654
+ const devBox = { left: g.cx - g.p.boxWidth / 2, right: g.cx + g.p.boxWidth / 2, top: g.p.cy - g.p.boxHeight / 2, bottom: g.p.cy + g.p.boxHeight / 2 };
1655
+ const textTopLayout = g.r.layout !== 'text-bottom';
1656
+ let placed: Geo['mascot'] | undefined;
1657
+ const fits = (rect: Rect, crossing: boolean): boolean => {
1658
+ const bounds: Rect = { left: margin, top: 0.012 * H, right: W - margin, bottom: H - 0.012 * H };
1659
+ if (crossing) {
1660
+ if (rect.top < bounds.top || rect.bottom > bounds.bottom) return false;
1661
+ } else if (!insideRect(rect, bounds)) return false;
1662
+ const blocks = (geo: Geo, dx: number) => {
1663
+ const shiftedRect = { ...rect, left: rect.left + dx, right: rect.right + dx };
1664
+ if (obstaclesOf(geo).some((o) => overlaps(shiftedRect, o, pad))) return true;
1665
+ return !!geo.focusCore && geo.focusCore.right > geo.focusCore.left && overlaps(shiftedRect, geo.focusCore);
1666
+ };
1667
+ if (blocks(g, 0)) return false;
1668
+ if (crossing && partner !== undefined) {
1669
+ const pg = geos[partner];
1670
+ // The half on the partner screen must clear ITS text / callout / focus too.
1671
+ if (blocks(pg, seamX === W ? -W : W)) return false;
1672
+ }
1673
+ return true;
1674
+ };
1675
+ const tryAt = (list: Array<[number, number]>, w: number, h: number, crossing: boolean) => {
1676
+ for (const [x, y] of list) {
1677
+ const rect = rectOf(x, y, w, h);
1678
+ if (fits(rect, crossing)) return rect;
1679
+ }
1680
+ return null;
1681
+ };
1682
+ for (const k of [1, 0.85, 0.7]) {
1683
+ const w = size * k;
1684
+ const h = (w * a.height) / a.width;
1685
+ for (const which of anchor === 'seam' ? (['seam', ...order.slice(1)] as MascotAnchor[]) : order) {
1686
+ let rect: Rect | null = null;
1687
+ let crossing = false;
1688
+ if (which === 'seam') {
1689
+ if (seamX === undefined) continue;
1690
+ crossing = true;
1691
+ rect = tryAt(
1692
+ [0.78, 0.68, 0.58, 0.88, 0.48, 0.38].map((f) => [seamX, f * H] as [number, number]),
1693
+ w,
1694
+ h,
1695
+ true
1696
+ );
1697
+ } else if (which === 'headline') {
1698
+ const L = g.rects.lines;
1699
+ const last = L[L.length - 1];
1700
+ const first = L[0];
1701
+ const nearY = textTopLayout ? textBottom + pad + h / 2 : textTop - pad - h / 2;
1702
+ rect = tryAt(
1703
+ [
1704
+ [last.right + pad + w / 2, (last.top + last.bottom) / 2],
1705
+ [last.left - pad - w / 2, (last.top + last.bottom) / 2],
1706
+ [first.right + pad + w / 2, (first.top + first.bottom) / 2],
1707
+ [first.left - pad - w / 2, (first.top + first.bottom) / 2],
1708
+ [W - margin - w / 2, nearY],
1709
+ [margin + w / 2, nearY]
1710
+ ],
1711
+ w,
1712
+ h,
1713
+ false
1714
+ );
1715
+ } else if (which === 'device-top') {
1716
+ const y = textTopLayout ? Math.max(devBox.top + h * 0.1, textBottom + pad + h / 2) : Math.min(devBox.bottom - h * 0.1, textTop - pad - h / 2);
1717
+ rect = tryAt(
1718
+ [
1719
+ [Math.min(devBox.right - w * 0.35, W - margin - w / 2), y],
1720
+ [Math.max(devBox.left + w * 0.35, margin + w / 2), y]
1721
+ ],
1722
+ w,
1723
+ h,
1724
+ false
1725
+ );
1726
+ } else {
1727
+ const ys = textTopLayout ? [0.8, 0.7, 0.6, 0.5] : [0.2, 0.3, 0.4, 0.5];
1728
+ rect = tryAt(
1729
+ ys.flatMap((f) => [
1730
+ [W - margin - w / 2, f * H] as [number, number],
1731
+ [margin + w / 2, f * H] as [number, number]
1732
+ ]),
1733
+ w,
1734
+ h,
1735
+ false
1736
+ );
1737
+ }
1738
+ if (rect) {
1739
+ const cxm = (rect.left + rect.right) / 2;
1740
+ const faceLeft = cxm > W / 2; // face into the canvas
1741
+ const flip = typeof m.flip === 'boolean' ? m.flip : a.faces ? (faceLeft ? a.faces === 'right' : a.faces === 'left') : false;
1742
+ placed = { rect, scale: w / a.width, flip, art: a, ...(crossing ? { seamPartner: partner, dx: seamX === W ? -W : W } : {}) };
1743
+ break;
1744
+ }
1745
+ }
1746
+ if (placed) break;
1747
+ }
1748
+ if (placed) g.mascot = placed;
1749
+ else {
1750
+ warnings.push({
1751
+ screen: g.r.index,
1752
+ code: 'mascot-skipped',
1753
+ message: `screen ${g.r.index + 1}: no room for the mascot that clears the text, callout and focus band — try another anchor or a smaller size`
1754
+ });
1755
+ }
1756
+ }
1757
+
1758
+ // ---- Pass 3c: layers per screen (bottom → top). ----
1759
+ const metrics: ComposeScreenMetrics[] = [];
1760
+ const decoration: Motif = (MOTIFS as readonly string[]).includes(style.panorama?.decoration ?? '') ? style.panorama!.decoration! : 'orbs';
1761
+ const darkText = setText === candidates.dark || (!brandBase && setText === DARK_TEXT_FALLBACK);
1762
+ const screens = geos.map((g) => {
1763
+ const { r, p, cx, rects } = g;
1764
+ const { W, H, typo, block, plan: screen } = r;
1765
+ const layers: LayerJSON[] = [];
1766
+ let background = r.background;
1767
+
1768
+ // Panorama background + seam decoration (BOTTOM of the stack).
1769
+ const inSpan = spanOf.get(r.index);
1770
+ if (inSpan) {
1771
+ const { span, k } = inSpan;
1772
+ const N = span.length;
1773
+ // One continuous fill across the whole span, offset per screen.
1774
+ const fill = spanFills.get(span[0])!;
1775
+ background = { type: 'solid', color: fill.kind === 'solid' ? fill.color : colorAt(fill.stops, rampT(fill.coords, k * W + W / 2, H / 2)) };
1776
+ const bg = makeShapeLayer({
1777
+ shape: 'rectangle',
1778
+ left: (N * W) / 2 - k * W,
1779
+ top: H / 2,
1780
+ width: N * W,
1781
+ height: H,
1782
+ name: `Panorama background (${k + 1}/${N})`,
1783
+ locked: true
1784
+ });
1785
+ Object.assign(bg.fabricData as Record<string, unknown>, {
1786
+ fill:
1787
+ fill.kind === 'solid'
1788
+ ? fill.color
1789
+ : {
1790
+ type: 'linear',
1791
+ gradientUnits: 'pixels',
1792
+ coords: { ...fill.coords },
1793
+ colorStops: fill.stops.map((s) => ({ offset: s.offset, color: s.color })),
1794
+ offsetX: 0,
1795
+ offsetY: 0
1796
+ },
1797
+ selectable: false,
1798
+ evented: false
1799
+ });
1800
+ layers.push(bg);
1801
+ if (decoration === 'orbs') {
1802
+ // A soft orb centred on each seam touching this screen, kept clear of both text blocks.
1803
+ for (let seam = 1; seam < N; seam++) {
1804
+ if (seam !== k && seam !== k + 1) continue;
1805
+ const orb = orbFor(span, seam);
1806
+ if (!orb) continue;
1807
+ layers.push(
1808
+ makeShapeLayer({
1809
+ shape: 'circle',
1810
+ left: seam * W - k * W,
1811
+ top: orb.cy,
1812
+ radius: orb.r,
1813
+ fill: 'rgba(255,255,255,0.14)',
1814
+ name: 'Panorama orb'
1815
+ })
1816
+ );
1817
+ }
1818
+ } else if (decoration !== 'none') {
1819
+ // H1: only the part of the span-wide motif this screen shows (O(N) per span, not O(N²)).
1820
+ const motif = motifPathForScreen(motifSubpaths(decoration, N * W, W, H), k, W, MOTIF_STROKE * W * 2);
1821
+ if (motif) layers.push(makeMotifLayer(motif, k, N, W, darkText, setText));
1822
+ }
1823
+ }
1824
+
1825
+ // A device straddling in from the previous screen sits under this screen's own subject.
1826
+ for (const [from, s] of straddle) {
1827
+ if (s.into !== r.index) continue;
1828
+ const fr = resolved[from];
1829
+ const fp = placements.get(from)!;
1830
+ layers.push(makeSubjectLayer(fr, fp, look, s.cx - W, `${getDeviceFrame(fr.plan.deviceId)?.name ?? 'Screenshot'} (continued)`));
1831
+ }
1832
+
1833
+ layers.push(makeSubjectLayer(r, p, look, cx));
1834
+
1835
+ // Callout (magnified selling element) over the device, under the text.
1836
+ if (g.callout) layers.push(makeCalloutLayer(r, g.callout, look));
224
1837
 
225
- // BOTTOM → TOP (canvas add order): device -> headline -> subheadline.
226
- const layers: LayerJSON[] = [frame, headline];
1838
+ // Mascots: this screen's own, plus a neighbour's half that crosses the seam onto this screen.
1839
+ if (g.mascot) layers.push(makeMascotLayer(g.mascot, 0, W, look, 'Mascot'));
1840
+ for (const other of geos) {
1841
+ if (other.mascot?.seamPartner === r.index) layers.push(makeMascotLayer(other.mascot, other.mascot.dx!, W, look, 'Mascot (continued)'));
1842
+ }
227
1843
 
228
- if (screen.subheadline?.trim()) {
229
- const sub = makeTextLayer({
230
- text: screen.subheadline,
1844
+ // Text block: badge row → headline → subheadline, anchored to the reserved text area.
1845
+ const headlineColor = textColorOf(r);
1846
+ const hue = brandBase ?? accentColor;
1847
+ const subTint = hue && !screen.subheadlineColor && isHexColor(headlineColor) ? mixHex(headlineColor, hue, SUBHEADLINE_TINT) : null;
1848
+ // Dense samples right behind a text box (the whole-canvas grid can miss a gradient's contrast
1849
+ // minimum between its points).
1850
+ const textSamples = (rect: Rect) => {
1851
+ const out: string[] = [];
1852
+ for (let gx = 0; gx <= 8; gx++) {
1853
+ for (let gy = 0; gy <= 4; gy++) {
1854
+ const x = rect.left + ((rect.right - rect.left) * gx) / 8;
1855
+ const y = rect.top + ((rect.bottom - rect.top) * gy) / 4;
1856
+ out.push(inSpan ? samplePanorama(spanFills.get(inSpan.span[0])!, inSpan.k, W, x, y) : sampleBackground(r.background, W, H, x, y));
1857
+ }
1858
+ }
1859
+ return out;
1860
+ };
1861
+ const subSamples = rects.sub ? textSamples(rects.sub) : [];
1862
+ // The tint must clear AA with a margin (SUBHEADLINE_TINT_MIN), so it never lands at 4.49.
1863
+ const subColor =
1864
+ screen.subheadlineColor ??
1865
+ (subTint && worstContrast(subTint, [...samplesOf(r, bgNow), ...subSamples]) >= SUBHEADLINE_TINT_MIN ? subTint : headlineColor);
1866
+ // Warn (never silently ship sub-AA text) when the colours actually used fall short right behind
1867
+ // the headline or the subheadline — once per screen.
1868
+ if (!warnedContrast.has(r.index)) {
1869
+ const checks: Array<[string, string, string[]]> = [];
1870
+ if (!screen.headlineColor && isHexColor(headlineColor)) checks.push(['headline', headlineColor, textSamples(rects.headline)]);
1871
+ if (rects.sub && !screen.subheadlineColor && isHexColor(subColor)) checks.push(['subheadline', subColor, subSamples]);
1872
+ for (const [what, color, samples] of checks) {
1873
+ const worst = worstContrast(color, samples);
1874
+ if (worst < MIN_CONTRAST) {
1875
+ warnedContrast.add(r.index);
1876
+ warnings.push({
1877
+ screen: r.index,
1878
+ code: 'contrast-low',
1879
+ message: `screen ${r.index + 1}: ${what} contrast is ${worst.toFixed(2)}:1 behind the text (WCAG AA needs 4.5) — use style.palette or a darker/lighter background`
1880
+ });
1881
+ break;
1882
+ }
1883
+ }
1884
+ }
1885
+ if (r.badge && rects.badge) {
1886
+ const text = r.badge;
1887
+ if (text.length > BADGE_MAX_CHARS) {
1888
+ warnings.push({ screen: r.index, code: 'badge-long', message: `screen ${r.index + 1}: badge "${text}" is long — keep it to 1–3 words` });
1889
+ }
1890
+ const fontSize = typo.badgeFont;
1891
+ const pillH = fontSize * BADGE_HEIGHT;
1892
+ const pillW = rects.badge.right - rects.badge.left;
1893
+ const cy = g.areaTop + pillH / 2;
1894
+ // Tonal: a solid pill in the accent (or the text colour), label in whichever passes best on it.
1895
+ const pillSolid = tonal ? (accentColor ?? (isHexColor(headlineColor) ? headlineColor : null)) : null;
1896
+ const labelColor = pillSolid
1897
+ ? [headlineColor, candidates.light, candidates.dark, '#FFFFFF', DARK_TEXT_FALLBACK]
1898
+ .filter(isHexColor)
1899
+ .sort((x, y) => contrastRatio(y, pillSolid) - contrastRatio(x, pillSolid))[0]
1900
+ : headlineColor;
1901
+ // The label must read at WCAG AA on the pill too (review Low): a solid pill is toned away
1902
+ // from the label until it does; a translucent one gets fainter (closer to the background,
1903
+ // on which the set colour already passes).
1904
+ let pillFill: string;
1905
+ if (pillSolid) {
1906
+ pillFill = pillSolid;
1907
+ // Toward whichever extreme contrasts more with the label (≥ 4.58:1 for any label colour).
1908
+ const away = contrastRatio(labelColor, '#000000') >= contrastRatio(labelColor, '#FFFFFF') ? '#000000' : '#FFFFFF';
1909
+ for (let t = 0.05; t <= 1 + 1e-9 && contrastRatio(labelColor, pillFill) < MIN_CONTRAST; t += 0.05) pillFill = mixHex(pillSolid, away, t);
1910
+ } else {
1911
+ const text = isHexColor(headlineColor) ? headlineColor : '#ffffff';
1912
+ const behind = inSpan ? samplePanorama(spanFills.get(inSpan.span[0])!, inSpan.k, W, W / 2, cy) : sampleBackground(r.background, W, H, W / 2, cy);
1913
+ let alpha = 0.18;
1914
+ while (alpha > 0.021 && contrastRatio(text, mixHex(behind, text, alpha)) < MIN_CONTRAST) alpha -= 0.02;
1915
+ pillFill = rgba(text, Math.round(alpha * 100) / 100);
1916
+ }
1917
+ const pill = makeShapeLayer({
1918
+ shape: 'rectangle',
231
1919
  left: W / 2,
232
- top: blockTop + block.headlineHeight + block.gap + block.subHeight / 2,
233
- width: textWidth,
234
- fontSize: block.subSize,
235
- fontWeight: '500',
236
- lineHeight: SUBHEADLINE_LINE_HEIGHT,
237
- fill: screen.subheadlineColor ?? headlineColor,
1920
+ top: cy,
1921
+ width: pillW,
1922
+ height: pillH,
1923
+ rx: pillH / 2,
1924
+ ry: pillH / 2,
1925
+ fill: pillFill,
1926
+ name: 'Badge'
1927
+ });
1928
+ const label = makeTextLayer({
1929
+ text,
1930
+ left: W / 2,
1931
+ top: cy,
1932
+ width: pillW,
1933
+ fontSize,
1934
+ fontFamily: font,
1935
+ fontWeight: '700',
1936
+ lineHeight: 1,
1937
+ fill: labelColor,
238
1938
  textAlign: 'center',
239
- name: 'Subheadline',
1939
+ name: 'Badge text',
240
1940
  templateRole: 'editable',
241
- templateKey: 'subheadline'
1941
+ templateKey: 'badge'
242
1942
  });
243
- // When it inherits the headline color, mute it slightly (standard Fabric `opacity`, editable
244
- // in the editor). An explicit subheadlineColor is used as-is.
245
- if (!screen.subheadlineColor) (sub.fabricData as Record<string, unknown>).opacity = SUBHEADLINE_OPACITY;
246
- layers.push(sub);
1943
+ layers.push(pill, label);
247
1944
  }
248
1945
 
1946
+ // Center origin (editor convention): left/top are the box CENTER.
1947
+ layers.push(
1948
+ makeTextLayer({
1949
+ text: g.text,
1950
+ left: W / 2,
1951
+ top: g.headlineTop + block.headlineHeight / 2,
1952
+ width: typo.textWidth,
1953
+ fontSize: typo.headlineSize,
1954
+ fontFamily: font,
1955
+ fontWeight: '800',
1956
+ lineHeight: HEADLINE_LINE_HEIGHT,
1957
+ fill: headlineColor,
1958
+ textAlign: 'center',
1959
+ name: 'Headline',
1960
+ templateRole: 'editable',
1961
+ templateKey: 'headline'
1962
+ })
1963
+ );
1964
+
1965
+ if (g.subText && rects.sub) {
1966
+ layers.push(
1967
+ makeTextLayer({
1968
+ text: g.subText,
1969
+ left: W / 2,
1970
+ top: rects.sub.top + block.subHeight / 2,
1971
+ width: typo.textWidth,
1972
+ fontSize: typo.subSize,
1973
+ fontFamily: font,
1974
+ fontWeight: SUBHEADLINE_WEIGHT,
1975
+ lineHeight: SUBHEADLINE_LINE_HEIGHT,
1976
+ fill: subColor,
1977
+ textAlign: 'center',
1978
+ name: 'Subheadline',
1979
+ templateRole: 'editable',
1980
+ templateKey: 'subheadline'
1981
+ })
1982
+ );
1983
+ }
1984
+
1985
+ const top = p.cy - p.boxHeight / 2;
1986
+ const bottom = p.cy + p.boxHeight / 2;
1987
+ const overshoot = r.layout === 'text-bottom' ? -top : bottom - H;
1988
+ metrics.push({
1989
+ index: r.index,
1990
+ presentation: r.presentation,
1991
+ layout: r.layout,
1992
+ group: r.group,
1993
+ scale: p.scale,
1994
+ widthFraction: (p.subject.width * p.scale) / W,
1995
+ top: top / H,
1996
+ bottom: bottom / H,
1997
+ overshoot: overshoot / H,
1998
+ bleedFraction: p.boxHeight > 0 ? overshoot / p.boxHeight : 0,
1999
+ mode: overshoot > 0 ? 'bleed' : 'clear',
2000
+ tangent: inTangentZone(overshoot, p.boxHeight, H),
2001
+ headlineSize: typo.headlineSize / W,
2002
+ headlineTop: g.headlineTop / H,
2003
+ tilt: p.angle,
2004
+ centerX: cx / W,
2005
+ role: r.role,
2006
+ headlineLines: block.headline,
2007
+ textColor: headlineColor,
2008
+ textColorSource: textSourceOf(r),
2009
+ contrast: contrastOf.get(r.index)!,
2010
+ ...(g.callout ? { callout: { ...g.callout.rect, magnification: g.callout.mag, focusCover: g.callout.focusCover } } : {}),
2011
+ ...(g.mascot ? { mascot: g.mascot.rect } : {})
2012
+ });
2013
+
249
2014
  return makeScreen({
250
- background: screen.background,
2015
+ background,
251
2016
  canvasWidth: W,
252
2017
  canvasHeight: H,
253
2018
  // Tag the device GROUP (multi-device) so a mixed plan lands as separate sidebar groups in
@@ -257,5 +2022,258 @@ export function composeTemplate(plan: ComposePlan): Template {
257
2022
  });
258
2023
  });
259
2024
 
260
- return makeTemplate({ name: plan.name, screens, tags: ['generated'] });
2025
+ // Copy + composition lint (warnings, never errors).
2026
+ resolved.forEach((r) => {
2027
+ const words = r.plan.headline.trim().split(/\s+/).filter(Boolean).length;
2028
+ const nn = r.index + 1;
2029
+ if (words > COPY_RULES.headlineMaxWords) {
2030
+ warnings.push({ screen: r.index, code: 'headline-words', message: `screen ${nn}: headline has ${words} words (aim for 3–5) — cut, don't shrink` });
2031
+ }
2032
+ if (r.block.headlineLines > COPY_RULES.headlineMaxLines) {
2033
+ warnings.push({
2034
+ screen: r.index,
2035
+ code: 'headline-lines',
2036
+ message: `screen ${nn}: headline wraps to ~${r.block.headlineLines} lines at the set size (max 2) — it grows the text area for EVERY screen`
2037
+ });
2038
+ }
2039
+ if (hasOrphan(r.block.headline)) {
2040
+ warnings.push({
2041
+ screen: r.index,
2042
+ code: 'headline-orphan',
2043
+ message: `screen ${nn}: the headline's last line is a single word ("${r.block.headline[r.block.headline.length - 1]}") — rephrase or place the break with \\n`
2044
+ });
2045
+ }
2046
+ if (r.block.subLines > COPY_RULES.subheadlineMaxLines) {
2047
+ warnings.push({
2048
+ screen: r.index,
2049
+ code: 'subheadline-lines',
2050
+ message: `screen ${nn}: subheadline wraps to ~${r.block.subLines} lines (max 1) — shorten it or drop it`
2051
+ });
2052
+ }
2053
+ const p = placements.get(r.index)!;
2054
+ if (p.zoom?.focusCropped) {
2055
+ warnings.push({ screen: r.index, code: 'zoom-focus-cropped', message: `screen ${nn}: the zoom card can't show the whole focus band — mark a tighter \`crop\`` });
2056
+ }
2057
+ });
2058
+ if (straddle.size > MAX_STRADDLES) {
2059
+ warnings.push({
2060
+ code: 'panorama-straddle-count',
2061
+ message: `${straddle.size} devices cross a seam — keep it to ${MAX_STRADDLES} per set (usually the hero) so the set stays calm`
2062
+ });
2063
+ }
2064
+ const tilted = resolved.filter((r) => r.tilt !== 0).length;
2065
+ if (tilted > COPY_RULES.maxTiltedScreens) {
2066
+ warnings.push({ code: 'tilt-count', message: `${tilted} screens are tilted — rotation is an accent: use it on at most ${COPY_RULES.maxTiltedScreens}` });
2067
+ }
2068
+ // Seam rule: nothing crossing a seam may touch a headline, and no focus band may cross a seam.
2069
+ for (const [from, s] of straddle) {
2070
+ const fr = resolved[from];
2071
+ const fp = placements.get(from)!;
2072
+ for (const [screenIdx, cx] of [
2073
+ [from, s.cx],
2074
+ [s.into, s.cx - fr.W]
2075
+ ] as const) {
2076
+ const box = { left: cx - fp.boxWidth / 2, right: cx + fp.boxWidth / 2, top: fp.cy - fp.boxHeight / 2, bottom: fp.cy + fp.boxHeight / 2 };
2077
+ const t = geos[screenIdx].rects;
2078
+ if (textRectList(t).some((rect) => rectsOverlap(rect, box))) {
2079
+ warnings.push({ screen: screenIdx, code: 'panorama-seam', message: `screen ${screenIdx + 1}: the straddling device overlaps the text` });
2080
+ }
2081
+ }
2082
+ const poly = focusPolygon(fr, fp, s.cx);
2083
+ if (poly && Math.max(...poly.map((pt) => pt.x)) > fr.W) {
2084
+ warnings.push({ screen: from, code: 'panorama-seam', message: `screen ${from + 1}: the seam crosses the focus band` });
2085
+ }
2086
+ }
2087
+
2088
+ metrics.sort((a, b) => a.index - b.index);
2089
+ return { template: makeTemplate({ name: plan.name, screens, tags: ['generated'] }), report: { warnings, screens: metrics } };
2090
+ }
2091
+
2092
+ const DARK_TEXT_FALLBACK = '#111827';
2093
+
2094
+ /**
2095
+ * Where a callout card goes: the crop magnified 1.6–2.2× (less only when the card would not fit,
2096
+ * never below CALLOUT_MAG.floor), centred over the crop's own position on the device, clamped to the
2097
+ * side margins and to the free band between the text block and the far edge. Null (+ warning) when
2098
+ * it can't be shown large enough.
2099
+ */
2100
+ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: TextRects, warnings: ComposeWarning[]) {
2101
+ const req = r.calloutReq!;
2102
+ const { W, H } = r;
2103
+ const shot = r.plan.screenshot;
2104
+ const crop = { x: req.x * shot.width, y: req.y * shot.height, w: req.w * shot.width, h: req.h * shot.height };
2105
+ const scr = p.subject.screen;
2106
+ // Canvas px per screenshot px as the editor fits it into the screen bounds (frameless: the image).
2107
+ const screenScale = (p.scale * scr.width) / shot.width;
2108
+ const centre = subjectPointToCanvas(
2109
+ p.subject,
2110
+ { x: scr.x + ((crop.x + crop.w / 2) / shot.width) * scr.width, y: scr.y + ((crop.y + crop.h / 2) / shot.height) * scr.height },
2111
+ { cx, cy: p.cy, scale: p.scale, angle: p.angle }
2112
+ );
2113
+ const texts = textRectList(rects);
2114
+ const gap = CALLOUT_TEXT_GAP * H;
2115
+ const [lo, hi] =
2116
+ r.layout === 'text-bottom'
2117
+ ? [CALLOUT_EDGE * H, Math.min(...texts.map((t) => t.top)) - gap]
2118
+ : [Math.max(...texts.map((t) => t.bottom)) + gap, H * (1 - CALLOUT_EDGE)];
2119
+ const baseW = crop.w * screenScale;
2120
+ const baseH = crop.h * screenScale;
2121
+ let mag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseW));
2122
+ mag = Math.min(mag, ((1 - 2 * CALLOUT_MARGIN) * W) / baseW, (hi - lo) / baseH);
2123
+ if (!(mag >= CALLOUT_MAG.floor)) {
2124
+ warnings.push({
2125
+ screen: r.index,
2126
+ code: 'callout-skipped',
2127
+ message: `screen ${r.index + 1}: the callout crop is too large to magnify (${mag.toFixed(2)}× fits) — mark a smaller \`callout\` region`
2128
+ });
2129
+ return null;
2130
+ }
2131
+ // M2: the focus band (canvas bbox, clipped to the canvas) and the callout's own source slice.
2132
+ const pose = { cx, cy: p.cy, scale: p.scale, angle: p.angle };
2133
+ const bboxOf = (pts: Array<{ x: number; y: number }>): Rect => ({
2134
+ left: Math.min(...pts.map((q) => q.x)),
2135
+ right: Math.max(...pts.map((q) => q.x)),
2136
+ top: Math.min(...pts.map((q) => q.y)),
2137
+ bottom: Math.max(...pts.map((q) => q.y))
2138
+ });
2139
+ const poly = focusPolygon(r, p, cx);
2140
+ const focusRect = poly ? bboxOf(poly) : null;
2141
+ if (focusRect) Object.assign(focusRect, { left: Math.max(0, focusRect.left), right: Math.min(W, focusRect.right), top: Math.max(0, focusRect.top), bottom: Math.min(H, focusRect.bottom) });
2142
+ const toCanvas = (fx: number, fy: number) => subjectPointToCanvas(p.subject, { x: scr.x + fx * scr.width, y: scr.y + fy * scr.height }, pose);
2143
+ const source = bboxOf([toCanvas(req.x, req.y), toCanvas(req.x + req.w, req.y), toCanvas(req.x + req.w, req.y + req.h), toCanvas(req.x, req.y + req.h)]);
2144
+ const cover = (card: Rect) => (focusRect ? focusCoverage(card, focusRect, source) : 0);
2145
+ const m = CALLOUT_MARGIN * W;
2146
+ // Candidates, best first: the largest magnification, then the position closest to the source
2147
+ // (centred on it, then moved up/down, then pushed out to a side edge to break out of the device).
2148
+ for (let k = mag; k >= CALLOUT_MAG.floor - 1e-9; k = k > CALLOUT_MAG.floor ? Math.max(CALLOUT_MAG.floor, k * 0.92) : k - 1) {
2149
+ const w = baseW * k;
2150
+ const h = baseH * k;
2151
+ const xs = [Math.min(Math.max(centre.x, m + w / 2), W - m - w / 2), m + w / 2, W - m - w / 2];
2152
+ const ys: number[] = [];
2153
+ const y0 = Math.min(Math.max(centre.y, lo + h / 2), hi - h / 2);
2154
+ for (let t = 0; t <= 24; t++) ys.push(lo + h / 2 + ((hi - lo - h) * t) / 24);
2155
+ let bestRect: Rect | null = null;
2156
+ let bestDist = Infinity;
2157
+ for (const x of xs) {
2158
+ for (const y of [y0, ...ys]) {
2159
+ const rect = rectOf(x, y, w, h);
2160
+ if (cover(rect) > CALLOUT_MAX_FOCUS_COVER + 1e-9) continue;
2161
+ const dist = Math.hypot((x - centre.x) / W, (y - centre.y) / H);
2162
+ if (dist < bestDist - 1e-9) {
2163
+ bestDist = dist;
2164
+ bestRect = rect;
2165
+ }
2166
+ }
2167
+ }
2168
+ if (bestRect) return { rect: bestRect, mag: k, crop, scale: screenScale * k, focusCover: cover(bestRect) };
2169
+ if (k <= CALLOUT_MAG.floor) break;
2170
+ }
2171
+ warnings.push({
2172
+ screen: r.index,
2173
+ code: 'callout-skipped',
2174
+ message: `screen ${r.index + 1}: no callout position hides less than ${Math.round(CALLOUT_MAX_FOCUS_COVER * 100)}% of the rest of the focus band — mark a smaller \`callout\` or a tighter \`focus\``
2175
+ });
2176
+ return null;
2177
+ }
2178
+
2179
+ function makeCalloutLayer(r: ResolvedScreen, c: NonNullable<ReturnType<typeof calloutGeometry>>, look: Look): LayerJSON {
2180
+ const id = generateLayerId();
2181
+ const radius = CALLOUT_RADIUS * r.W;
2182
+ const fabricData: Record<string, unknown> = {
2183
+ type: 'image',
2184
+ src: r.plan.screenshot.url,
2185
+ crossOrigin: 'anonymous',
2186
+ left: (c.rect.left + c.rect.right) / 2,
2187
+ top: (c.rect.top + c.rect.bottom) / 2,
2188
+ width: c.crop.w,
2189
+ height: c.crop.h,
2190
+ cropX: c.crop.x,
2191
+ cropY: c.crop.y,
2192
+ scaleX: c.scale,
2193
+ scaleY: c.scale,
2194
+ originX: 'center',
2195
+ originY: 'center',
2196
+ clipPath: {
2197
+ type: 'Rect',
2198
+ left: 0,
2199
+ top: 0,
2200
+ width: c.crop.w,
2201
+ height: c.crop.h,
2202
+ rx: radius / c.scale,
2203
+ ry: radius / c.scale,
2204
+ originX: 'center',
2205
+ originY: 'center'
2206
+ },
2207
+ imageCornerRadius: radius,
2208
+ layerId: id,
2209
+ layerType: 'image'
2210
+ };
2211
+ if (look.shadows) fabricData.shadow = shadowFor(r.W, c.scale, look, CALLOUT_SHADOW);
2212
+ return { id, name: 'Callout', type: 'image', visible: true, locked: false, fabricData };
2213
+ }
2214
+
2215
+ function makeMascotLayer(m: { rect: Rect; scale: number; flip: boolean; art: ComposeArt }, dx: number, W: number, look: Look, name: string): LayerJSON {
2216
+ const id = generateLayerId();
2217
+ const fabricData: Record<string, unknown> = {
2218
+ type: 'image',
2219
+ src: m.art.url,
2220
+ crossOrigin: 'anonymous',
2221
+ left: (m.rect.left + m.rect.right) / 2 + dx,
2222
+ top: (m.rect.top + m.rect.bottom) / 2,
2223
+ width: m.art.width,
2224
+ height: m.art.height,
2225
+ scaleX: m.scale,
2226
+ scaleY: m.scale,
2227
+ originX: 'center',
2228
+ originY: 'center',
2229
+ layerId: id,
2230
+ layerType: 'image'
2231
+ };
2232
+ if (m.flip) fabricData.flipX = true;
2233
+ if (look.shadows) fabricData.shadow = shadowFor(W, m.scale, look, MASCOT_SHADOW);
2234
+ return { id, name, type: 'image', visible: true, locked: false, fabricData };
2235
+ }
2236
+
2237
+ const MOTIF_STROKE = 0.008; // × W
2238
+
2239
+ /**
2240
+ * Span screen `k`'s part of the motif (already in screen coordinates — see `motifPathForScreen`).
2241
+ * Fabric centres a Path on its bbox, so `left`/`top` = the bbox centre.
2242
+ */
2243
+ function makeMotifLayer(motif: { path: PathCommand[]; bbox: Rect }, k: number, N: number, W: number, darkText: boolean, text: string): LayerJSON {
2244
+ const id = generateLayerId();
2245
+ const stroke = darkText ? 'rgba(255,255,255,0.42)' : rgba(isHexColor(text) ? text : '#FFFFFF', 0.16);
2246
+ return {
2247
+ id,
2248
+ name: `Panorama motif (${k + 1}/${N})`,
2249
+ type: 'shape',
2250
+ visible: true,
2251
+ locked: true,
2252
+ fabricData: {
2253
+ type: 'Path',
2254
+ path: motif.path.map((c) => [...c]),
2255
+ left: (motif.bbox.left + motif.bbox.right) / 2,
2256
+ top: (motif.bbox.top + motif.bbox.bottom) / 2,
2257
+ originX: 'center',
2258
+ originY: 'center',
2259
+ fill: 'rgba(0,0,0,0)',
2260
+ stroke,
2261
+ strokeWidth: MOTIF_STROKE * W,
2262
+ strokeLineJoin: 'round',
2263
+ selectable: false,
2264
+ evented: false,
2265
+ layerId: id,
2266
+ layerType: 'shape',
2267
+ shapeType: 'path'
2268
+ }
2269
+ };
2270
+ }
2271
+
2272
+ /**
2273
+ * Deterministically assemble a Template from a plan (see `composeSet` for the set-wide layout rules
2274
+ * and the lint report). Text layers are marked editable so the user can tweak them in the editor.
2275
+ * Claude decides the plan (benefit, copy, device, palette, layout, style); this turns it into valid DSL.
2276
+ */
2277
+ export function composeTemplate(plan: ComposePlan): Template {
2278
+ return composeSet(plan).template;
261
2279
  }