@appshoteditor/shot-dsl 0.4.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
@@ -2,7 +2,7 @@ import type { BackgroundJSON, ColorStop, LayerJSON, Template } from './types';
2
2
  import { makeTextLayer, makeShapeLayer, makeScreen, makeTemplate } from './builders';
3
3
  import { makeDeviceFrameLayer } from './frames';
4
4
  import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
5
- import { generateLayerId } from './validate';
5
+ import { generateLayerId, isUploadedScreenshotSrc } from './validate';
6
6
  import {
7
7
  NO_TANGENT,
8
8
  focusCorners,
@@ -19,7 +19,20 @@ import {
19
19
  type Subject,
20
20
  type VerticalResult
21
21
  } from './layout-system';
22
- import { darken, isHexColor, lighten, mixHex, readableTextOn, rgba } from './color';
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';
23
36
 
24
37
  /**
25
38
  * Editor-unit canvas dimensions for a device, by frame class — so a plan that mixes iPhone, iPad,
@@ -86,22 +99,86 @@ export interface ComposeCrop {
86
99
 
87
100
  /** Set-wide background system, used for screens that omit `background`. */
88
101
  export interface ComposePalette {
89
- /** `family`: every screen shares one gradient through `colors`. `sequence`: screen i uses colors[i % n]. */
90
- mode: 'family' | 'sequence';
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';
91
109
  colors: string[];
110
+ /** `tonal` only: `light` (pale tints, dark text), `vivid` (the brand colour itself, default), `deep`. */
111
+ tone?: PaletteTone;
92
112
  }
93
113
 
114
+ export const COMPOSE_PALETTE_TONES: readonly PaletteTone[] = ['light', 'vivid', 'deep'];
115
+
94
116
  export interface ComposePanorama {
95
- /** Runs of adjacent screen indices sharing one continuous background, e.g. [[0, 1], [2, 3]]. */
96
- spans: number[][];
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[][];
97
122
  /**
98
123
  * Devices that cross a seam into the next screen: `true` = the first screen of EVERY span, or a
99
124
  * list of screen indices (each must be the first screen of a span). Default none. More than one
100
125
  * straddle per set is allowed but lint-warned (`panorama-straddle-count`).
101
126
  */
102
127
  straddle?: boolean | number[];
103
- /** Soft decorative circles straddling each seam. Default `orbs`. */
104
- decoration?: 'orbs' | 'none';
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';
105
182
  }
106
183
 
107
184
  /** Plan-level style (all optional; an empty style composes exactly like a plan without one). */
@@ -116,6 +193,14 @@ export interface ComposeStyle {
116
193
  panorama?: ComposePanorama;
117
194
  /** Font family for all text (one of COMPOSE_FONTS). Default Inter. */
118
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;
119
204
  }
120
205
 
121
206
  /** One screen's worth of plan input (the skill decides these per benefit). */
@@ -145,6 +230,13 @@ export interface ComposeScreenPlan {
145
230
  tilt?: number;
146
231
  /** Short social-proof pill above the headline, e.g. "Teacher-approved". */
147
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;
148
240
  }
149
241
 
150
242
  export interface ComposePlan {
@@ -153,6 +245,8 @@ export interface ComposePlan {
153
245
  canvasWidth?: number;
154
246
  canvasHeight?: number;
155
247
  style?: ComposeStyle;
248
+ /** 0.5.0: brand art (mascots) referenced by `mascot.art`. */
249
+ art?: ComposeArt[];
156
250
  }
157
251
 
158
252
  export interface ComposeWarning {
@@ -168,7 +262,11 @@ export interface ComposeWarning {
168
262
  | 'tilt-reduced'
169
263
  | 'panorama-straddle-count'
170
264
  | 'zoom-focus-cropped'
171
- | 'badge-long';
265
+ | 'badge-long'
266
+ | 'headline-orphan'
267
+ | 'contrast-low'
268
+ | 'callout-skipped'
269
+ | 'mascot-skipped';
172
270
  message: string;
173
271
  }
174
272
 
@@ -200,8 +298,27 @@ export interface ComposeScreenMetrics {
200
298
  tilt: number;
201
299
  /** Horizontal centre ÷ W (≠ 0.5 for a straddling panorama device). */
202
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;
203
318
  }
204
319
 
320
+ export type ScreenRole = 'hero' | 'accent' | 'set';
321
+
205
322
  export interface ComposeReport {
206
323
  warnings: ComposeWarning[];
207
324
  screens: ComposeScreenMetrics[];
@@ -221,10 +338,21 @@ const TYPE_UNIT_HEIGHT_CAP = 0.55; // unit = min(W, 0.55·H)
221
338
  /** × unit — ONE headline size for the whole set (copy that doesn't fit is a lint warning, never a shrink). */
222
339
  export const HEADLINE_SIZE = 0.085;
223
340
  const HEADLINE_LINE_HEIGHT = 1.1;
224
- const SUBHEADLINE_RATIO = 0.55; // subheadline size ÷ headline size
341
+ const SUBHEADLINE_RATIO = 0.5; // subheadline size ÷ headline size (a real second tier)
225
342
  const SUBHEADLINE_LINE_HEIGHT = 1.25;
226
- const SUBHEADLINE_OPACITY = 0.85;
227
- const TEXT_WIDTH = 0.84; // × W → 8% side padding each side
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;
228
356
  /**
229
357
  * Conservative per-font text metrics for line-wrap estimates (and so the reserved text area):
230
358
  * px per character "unit" (see `charUnits`; 1 unit ≈ an average lowercase letter) × the font size.
@@ -254,7 +382,7 @@ const metricsOf = (m: TextMetrics) =>
254
382
  typeof m === 'number' ? { charWidth: m, mono: false } : { charWidth: FONT_CHAR_WIDTH[m] ?? AVG_CHAR_WIDTH, mono: MONOSPACE_FONTS.has(m) };
255
383
 
256
384
  const EDGE_MARGIN = 0.055; // × H — gap between the text block and the canvas edge
257
- const TEXT_GAP = 0.3; // × headline font size — headline ↔ subheadline gap
385
+ const TEXT_GAP = 0.45; // × headline font size — headline ↔ subheadline gap
258
386
  const DEVICE_GAP = 0.04; // × unit — text block ↔ device gap
259
387
 
260
388
  /** Copy rules (lint). */
@@ -272,13 +400,41 @@ const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel
272
400
  const FRAMELESS_RADIUS = 0.1; // × rendered width
273
401
  const ZOOM_WIDTH = 0.88; // × W
274
402
  const ZOOM_RADIUS = 0.05; // × W
275
- const ZOOM_MAX_MAG = 2; // focus-derived zoom: at most 2× the full-width fit
276
403
  const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
277
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
278
433
 
279
434
  // Panorama.
280
- const STRADDLE_OVERLAP = 0.18; // × the device's rendered bounding-box width that crosses the seam
281
- const STRADDLE_MIN = 0.06; // below this a straddle is not worth it (skipped + warning)
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;
282
438
  const MAX_STRADDLES = 1; // seam crossings per set before a lint warning
283
439
  const ORB_RADIUS = 0.34; // × W
284
440
  const ORB_Y = 0.7; // × H
@@ -307,6 +463,8 @@ const LAYOUTS: Record<ComposeLayout, LayoutSpec> = {
307
463
  */
308
464
  function charUnits(ch: string): number {
309
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;
310
468
  if ("iljI.,:;!|'’".includes(ch)) return 0.5;
311
469
  if ('ftr()[]-–'.includes(ch)) return 0.7;
312
470
  if ('mwMW'.includes(ch)) return 1.55;
@@ -314,14 +472,17 @@ function charUnits(ch: string): number {
314
472
  return 1;
315
473
  }
316
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
+
317
478
  /** Estimated rendered width of `text` (single line) — the width model behind `estimateLines`. */
318
479
  export function estimateTextWidth(text: string, fontSize: number, metrics: TextMetrics = 'Inter'): number {
319
480
  const { charWidth, mono } = metricsOf(metrics);
320
- return [...text].reduce((sum, ch) => sum + (mono ? 1 : charUnits(ch)), 0) * fontSize * charWidth;
481
+ return [...text].reduce((sum, ch) => sum + (isWideChar(ch) ? WIDE_CHAR_UNITS : mono ? 1 : charUnits(ch)), 0) * fontSize * charWidth;
321
482
  }
322
483
 
323
484
  /**
324
- * Greedy word-wrap estimate (Fabric Textbox semantics: wrap at spaces, break over-long words) of how
485
+ * Greedy word-wrap estimate (Fabric Textbox semantics: wrap at spaces) of how
325
486
  * many lines `text` takes at `fontSize` in a box `width` wide, in the font `metrics` (a COMPOSE_FONTS
326
487
  * name, or a raw px-per-unit factor).
327
488
  */
@@ -329,7 +490,7 @@ export function estimateLines(text: string, fontSize: number, width: number, met
329
490
  const { charWidth, mono } = metricsOf(metrics);
330
491
  const unit = fontSize * charWidth;
331
492
  const maxUnits = Math.max(1, width / unit);
332
- const cu = (ch: string) => (mono ? 1 : charUnits(ch));
493
+ const cu = (ch: string) => (isWideChar(ch) ? WIDE_CHAR_UNITS : mono ? 1 : charUnits(ch));
333
494
  const units = (w: string) => [...w].reduce((sum, ch) => sum + cu(ch), 0);
334
495
  let lines = 0;
335
496
  for (const paragraph of text.split('\n')) {
@@ -345,7 +506,9 @@ export function estimateLines(text: string, fontSize: number, width: number, met
345
506
  lines++;
346
507
  current = len;
347
508
  }
348
- // A single word longer than a line wraps mid-word in Fabric's Textbox.
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.
349
512
  while (current > maxUnits) {
350
513
  lines++;
351
514
  current -= maxUnits;
@@ -375,6 +538,9 @@ interface Typography {
375
538
  }
376
539
 
377
540
  interface TextBlock {
541
+ /** The headline as set (balanced / explicit breaks). */
542
+ headline: string[];
543
+ sub: string[];
378
544
  headlineLines: number;
379
545
  headlineHeight: number;
380
546
  subLines: number;
@@ -383,14 +549,23 @@ interface TextBlock {
383
549
  height: number;
384
550
  }
385
551
 
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
+ }
558
+
386
559
  function measureTextBlock(screen: ComposeScreenPlan, t: Omit<Typography, 'textArea'>): TextBlock {
387
- const headlineLines = estimateLines(screen.headline, t.headlineSize, t.textWidth, t.font);
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);
388
562
  const headlineHeight = headlineLines * t.headlineSize * HEADLINE_LINE_HEIGHT;
389
563
  const hasSub = !!screen.subheadline?.trim();
390
- const subLines = hasSub ? estimateLines(screen.subheadline!, t.subSize, t.textWidth, t.font) : 0;
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);
391
566
  const subHeight = subLines * t.subSize * SUBHEADLINE_LINE_HEIGHT;
392
567
  const gap = hasSub ? t.headlineSize * TEXT_GAP : 0;
393
- return { headlineLines, headlineHeight, subLines, subHeight, gap, height: t.badgeRow + headlineHeight + gap + subHeight };
568
+ return { headline, sub, headlineLines, headlineHeight, subLines, subHeight, gap, height: t.badgeRow + headlineHeight + gap + subHeight };
394
569
  }
395
570
 
396
571
  /** Everything decided per screen before layers are built. */
@@ -407,6 +582,14 @@ interface ResolvedScreen {
407
582
  typo: Typography;
408
583
  block: TextBlock;
409
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;
410
593
  }
411
594
 
412
595
  const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
@@ -438,10 +621,14 @@ function evenStops(colors: string[]): ColorStop[] {
438
621
 
439
622
  const linear = (colorStops: ColorStop[]): BackgroundJSON => ({ type: 'gradient', gradient: { type: 'linear', colorStops } });
440
623
 
441
- /** Background from the plan-level palette (null when there is none / it's unusable). */
442
- function paletteBackground(palette: ComposePalette | undefined, index: number): BackgroundJSON | null {
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 {
443
626
  const colors = (palette?.colors ?? []).filter(isHexColor);
444
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
+ }
445
632
  if (palette.mode === 'sequence') {
446
633
  const c = colors[index % colors.length];
447
634
  return linear([
@@ -560,9 +747,9 @@ function nearStart(t: Typography): number {
560
747
 
561
748
  /**
562
749
  * Fit a zoom crop (natural px) to the card aspect `A` = height / width, centred on the requested
563
- * region. An explicit `crop` keeps its full width (the model chose it); a focus-derived band is
564
- * zoomed into its centre, up to ZOOM_MAX_MAG× the full-width fit. The height always covers the
565
- * requested band when the image allows (else `focusCropped`).
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`).
566
753
  */
567
754
  function fitCrop(
568
755
  shot: { width: number; height: number },
@@ -570,7 +757,9 @@ function fitCrop(
570
757
  A: number,
571
758
  explicit: boolean
572
759
  ): { cropX: number; cropY: number; cropW: number; cropH: number; focusCropped: boolean } {
573
- const minW = explicit ? req.w : shot.width / ZOOM_MAX_MAG;
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;
574
763
  let w = Math.min(shot.width, Math.max(minW, req.h / A));
575
764
  let h = w * A;
576
765
  if (h > shot.height) {
@@ -839,14 +1028,21 @@ function placeGroup(
839
1028
  return out;
840
1029
  }
841
1030
 
842
- function shadowFor(W: number, scale: number) {
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) {
843
1039
  // Fabric scales shadow blur/offset by the object's scale (the editor's shadow controls use the
844
1040
  // same convention), so express them in the image's own units.
845
- return { color: SHADOW.color, blur: (SHADOW.blur * W) / scale, offsetX: 0, offsetY: (SHADOW.offsetY * W) / scale };
1041
+ return { color: look.shadowColor, blur: (spec.blur * W) / scale, offsetX: 0, offsetY: (spec.offsetY * W) / scale };
846
1042
  }
847
1043
 
848
1044
  /** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
849
- function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement): LayerJSON {
1045
+ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look): LayerJSON {
850
1046
  const id = generateLayerId();
851
1047
  const shot = r.plan.screenshot;
852
1048
  const zoom = p.zoom;
@@ -879,10 +1075,10 @@ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement): LayerJSON {
879
1075
  originY: 'center'
880
1076
  },
881
1077
  imageCornerRadius: radius,
882
- shadow: shadowFor(r.W, p.scale),
883
1078
  layerId: id,
884
1079
  layerType: 'image'
885
1080
  };
1081
+ if (look.shadows) fabricData.shadow = shadowFor(r.W, p.scale, look);
886
1082
  if (zoom) {
887
1083
  fabricData.cropX = zoom.cropX;
888
1084
  fabricData.cropY = zoom.cropY;
@@ -898,11 +1094,11 @@ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement): LayerJSON {
898
1094
  };
899
1095
  }
900
1096
 
901
- function makeSubjectLayer(r: ResolvedScreen, p: Placement, cx = p.cx, name?: string): LayerJSON {
1097
+ function makeSubjectLayer(r: ResolvedScreen, p: Placement, look: Look, cx = p.cx, name?: string): LayerJSON {
902
1098
  if (r.presentation === 'device') {
903
1099
  // One device layer; the screenshot rides on it as `fabricData.screenshot` and the editor
904
1100
  // places + clips it under the frame on import (see DeviceScreenshotJSON).
905
- return makeDeviceFrameLayer({
1101
+ const layer = makeDeviceFrameLayer({
906
1102
  deviceId: r.plan.deviceId,
907
1103
  screenshotUrl: r.plan.screenshot.url,
908
1104
  screenshotWidth: r.plan.screenshot.width,
@@ -915,8 +1111,20 @@ function makeSubjectLayer(r: ResolvedScreen, p: Placement, cx = p.cx, name?: str
915
1111
  angle: p.angle,
916
1112
  name
917
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;
918
1126
  }
919
- const layer = makeScreenshotImageLayer(r, p);
1127
+ const layer = makeScreenshotImageLayer(r, p, look);
920
1128
  (layer.fabricData as Record<string, unknown>).left = cx;
921
1129
  if (name) layer.name = name;
922
1130
  return layer;
@@ -930,11 +1138,15 @@ function focusPolygon(r: ResolvedScreen, p: Placement, cx: number) {
930
1138
  }
931
1139
 
932
1140
  interface TextRects {
933
- badge?: { left: number; top: number; right: number; bottom: number };
934
- headline: { left: number; top: number; right: number; bottom: number };
935
- sub?: { left: number; top: number; right: number; bottom: number };
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;
936
1146
  }
937
1147
 
1148
+ const textRectList = (t: TextRects): Rect[] => [t.badge, t.headline, t.sub].filter((x): x is Rect => !!x);
1149
+
938
1150
  function validateSpans(plan: ComposePlan, dims: Array<{ W: number; H: number }>): number[][] {
939
1151
  const spans = plan.style?.panorama?.spans ?? [];
940
1152
  const seen = new Set<number>();
@@ -959,11 +1171,42 @@ function validateSpans(plan: ComposePlan, dims: Array<{ W: number; H: number }>)
959
1171
  return spans;
960
1172
  }
961
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
+
962
1200
  /**
963
1201
  * Two-pass SET layout: measure every text block → one type size + a text area reserved for the
964
1202
  * tallest block (per canvas size) → one subject scale/baseline per group (canvas, presentation,
965
- * subject, layout, tilt) → the no-tangent bleed rule (respecting `focus`) → per-screen layers.
1203
+ * subject, layout, tilt, role) → the no-tangent bleed rule (respecting `focus`) → per-screen layers.
966
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.
967
1210
  */
968
1211
  export function composeSet(plan: ComposePlan): { template: Template; report: ComposeReport } {
969
1212
  const style = plan.style ?? {};
@@ -971,6 +1214,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
971
1214
  const bleedPref: BleedPreference = (COMPOSE_BLEEDS as readonly string[]).includes(style.bleed ?? '') ? style.bleed! : 'auto';
972
1215
  const font = style.font && COMPOSE_FONTS.includes(style.font) ? style.font : 'Inter';
973
1216
  const tiltScreens = new Set(style.tiltScreens ?? []);
1217
+ const n = plan.screens.length;
974
1218
 
975
1219
  // Per-screen canvas dims: honor explicit plan-level dims (backward compatible), else derive from
976
1220
  // the screen's device class so a mixed-device plan gets the correct aspect per screen.
@@ -986,18 +1230,35 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
986
1230
  const spanOf = new Map<number, { span: number[]; k: number }>();
987
1231
  for (const span of spans) span.forEach((idx, k) => spanOf.set(idx, { span, k }));
988
1232
 
989
- // Pass 1: set typography per canvas size (tallest text block wins).
990
- const typoByDims = new Map<string, Typography>();
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
+ }
1241
+
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>();
991
1255
  const dimsKey = (d: { W: number; H: number }) => `${d.W}x${d.H}`;
992
- plan.screens.forEach((_, i) => {
993
- const key = dimsKey(dims[i]);
994
- if (typoByDims.has(key)) return;
1256
+ const makeTypo = (i: number, scale: number, hasBadge: boolean): Typography => {
995
1257
  const { W, H } = dims[i];
996
1258
  const unit = Math.min(W, H * TYPE_UNIT_HEIGHT_CAP);
997
- const headlineSize = unit * HEADLINE_SIZE;
998
- const hasBadge = plan.screens.some((s, j) => dimsKey(dims[j]) === key && !!s.badge?.trim());
999
- const badgeFont = headlineSize * BADGE_FONT;
1000
- typoByDims.set(key, {
1259
+ const headlineSize = unit * HEADLINE_SIZE * scale;
1260
+ const badgeFont = unit * HEADLINE_SIZE * BADGE_FONT;
1261
+ return {
1001
1262
  W,
1002
1263
  H,
1003
1264
  unit,
@@ -1008,13 +1269,55 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1008
1269
  deviceGap: unit * DEVICE_GAP,
1009
1270
  badgeFont,
1010
1271
  font,
1011
- badgeRow: hasBadge ? badgeFont * BADGE_HEIGHT + headlineSize * BADGE_GAP : 0,
1272
+ badgeRow: hasBadge ? badgeFont * BADGE_HEIGHT + unit * HEADLINE_SIZE * BADGE_GAP : 0,
1012
1273
  textArea: 0
1013
- });
1014
- });
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
+ }
1297
+ } else {
1298
+ const hasBadge = plan.screens.some((_, j) => j !== heroIndex && dimsKey(dims[j]) === dimsKey(dims[i]) && !!badgeOf(j));
1299
+ typo = makeTypo(i, 1, hasBadge);
1300
+ }
1301
+ typoByKey.set(key, typo);
1302
+ return typo;
1303
+ };
1015
1304
 
1305
+ const calloutsAuto = style.callouts === 'auto';
1016
1306
  const resolved: ResolvedScreen[] = plan.screens.map((screen, i) => {
1017
- const typo = typoByDims.get(dimsKey(dims[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
+ }
1018
1321
  const block = measureTextBlock(screen, typo);
1019
1322
  typo.textArea = Math.max(typo.textArea, block.height);
1020
1323
  const presentation: ComposePresentation = (COMPOSE_PRESENTATIONS as readonly string[]).includes(screen.presentation ?? '')
@@ -1023,10 +1326,19 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1023
1326
  ? style.presentation!
1024
1327
  : 'device';
1025
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
+ }
1026
1339
  if (presentation === 'zoom' && layout === 'device-bleed') layout = 'text-top';
1027
- const rawTilt = typeof screen.tilt === 'number' ? screen.tilt : tiltScreens.has(i) ? (style.tilt ?? 0) : 0;
1028
1340
  const tilt = Number.isFinite(rawTilt) ? Math.max(-30, Math.min(30, rawTilt)) : 0;
1029
- const fromPalette = screen.background ? null : paletteBackground(style.palette, i);
1341
+ const fromPalette = screen.background ? null : paletteBackground(style.palette, i, role === 'hero' ? 0 : i);
1030
1342
  const background = screen.background ?? fromPalette ?? { type: 'solid', color: '#1F2937' };
1031
1343
  const backgroundFromStyle = !screen.background;
1032
1344
  const subjectKey =
@@ -1035,7 +1347,14 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1035
1347
  : presentation === 'frameless'
1036
1348
  ? `shot:${screen.screenshot.width}x${screen.screenshot.height}`
1037
1349
  : 'card';
1038
- const group = `${dims[i].W}x${dims[i].H}|${presentation}|${subjectKey}|${layout}|tilt:${tilt}`;
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`);
1039
1358
  return {
1040
1359
  index: i,
1041
1360
  plan: screen,
@@ -1048,7 +1367,12 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1048
1367
  backgroundFromStyle,
1049
1368
  typo,
1050
1369
  block,
1051
- group
1370
+ group,
1371
+ role,
1372
+ bleed,
1373
+ badge: badgeOf(i),
1374
+ calloutReq,
1375
+ mascot
1052
1376
  };
1053
1377
  });
1054
1378
 
@@ -1068,7 +1392,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1068
1392
  const placements = new Map<number, Placement>();
1069
1393
  for (const members of groups.values()) {
1070
1394
  const asStraddle = members.every((m) => wantsStraddle.has(m.index));
1071
- for (const [i, p] of placeGroup(members, bleedPref, warnings, asStraddle)) placements.set(i, p);
1395
+ for (const [i, p] of placeGroup(members, members[0].bleed, warnings, asStraddle)) placements.set(i, p);
1072
1396
  }
1073
1397
 
1074
1398
  // Panorama: straddling devices (first screen of a span) cross into the next screen.
@@ -1088,7 +1412,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1088
1412
  warnings.push({
1089
1413
  screen: i,
1090
1414
  code: 'straddle-skipped',
1091
- message: `screen ${i + 1}: device kept inside its screen — crossing the seam would put its focus band across it (or break the side margin)`
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)`
1092
1416
  });
1093
1417
  continue;
1094
1418
  }
@@ -1103,12 +1427,113 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1103
1427
  }
1104
1428
  }
1105
1429
 
1106
- // Span fills (validated up front, so a bad span background fails before any layer is built).
1107
- const spanFills = new Map<number, SpanFill>();
1108
- for (const span of spans) {
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 => {
1109
1439
  const r0 = resolved[span[0]];
1110
- spanFills.set(span[0], spanFillFor(r0.background, span[0], span.length, r0.W, r0.H));
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);
1111
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));
1112
1537
  /** Vertical band of a screen's reserved text block (with a small breathing gap). */
1113
1538
  const textBand = (r: ResolvedScreen) => {
1114
1539
  const top = r.layout === 'text-bottom' ? r.H - r.typo.margin - r.typo.textArea : r.typo.margin;
@@ -1143,12 +1568,200 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1143
1568
  return { cy, r };
1144
1569
  };
1145
1570
 
1146
- // Pass 3: layers per screen.
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 [];
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). ----
1147
1759
  const metrics: ComposeScreenMetrics[] = [];
1148
- const textRectsByScreen = new Map<number, TextRects>();
1149
- const screens = resolved.map((r) => {
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;
1150
1764
  const { W, H, typo, block, plan: screen } = r;
1151
- const p = placements.get(r.index)!;
1152
1765
  const layers: LayerJSON[] = [];
1153
1766
  let background = r.background;
1154
1767
 
@@ -1157,9 +1770,9 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1157
1770
  if (inSpan) {
1158
1771
  const { span, k } = inSpan;
1159
1772
  const N = span.length;
1160
- // The span's FIRST screen's (already resolved) background runs across the whole span.
1773
+ // One continuous fill across the whole span, offset per screen.
1161
1774
  const fill = spanFills.get(span[0])!;
1162
- background = { type: 'solid', color: fill.kind === 'solid' ? fill.color : fill.stops[0].color };
1775
+ background = { type: 'solid', color: fill.kind === 'solid' ? fill.color : colorAt(fill.stops, rampT(fill.coords, k * W + W / 2, H / 2)) };
1163
1776
  const bg = makeShapeLayer({
1164
1777
  shape: 'rectangle',
1165
1778
  left: (N * W) / 2 - k * W,
@@ -1185,7 +1798,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1185
1798
  evented: false
1186
1799
  });
1187
1800
  layers.push(bg);
1188
- if ((style.panorama?.decoration ?? 'orbs') === 'orbs') {
1801
+ if (decoration === 'orbs') {
1189
1802
  // A soft orb centred on each seam touching this screen, kept clear of both text blocks.
1190
1803
  for (let seam = 1; seam < N; seam++) {
1191
1804
  if (seam !== k && seam !== k + 1) continue;
@@ -1202,6 +1815,10 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1202
1815
  })
1203
1816
  );
1204
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));
1205
1822
  }
1206
1823
  }
1207
1824
 
@@ -1210,40 +1827,93 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1210
1827
  if (s.into !== r.index) continue;
1211
1828
  const fr = resolved[from];
1212
1829
  const fp = placements.get(from)!;
1213
- const cont = makeSubjectLayer(fr, fp, s.cx - W, `${getDeviceFrame(fr.plan.deviceId)?.name ?? 'Screenshot'} (continued)`);
1214
- layers.push(cont);
1830
+ layers.push(makeSubjectLayer(fr, fp, look, s.cx - W, `${getDeviceFrame(fr.plan.deviceId)?.name ?? 'Screenshot'} (continued)`));
1215
1831
  }
1216
1832
 
1217
- const cx = straddle.get(r.index)?.cx ?? p.cx;
1218
- layers.push(makeSubjectLayer(r, p, cx));
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));
1837
+
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
+ }
1219
1843
 
1220
1844
  // Text block: badge row → headline → subheadline, anchored to the reserved text area.
1221
- const areaTop = r.layout === 'text-bottom' ? H - typo.margin - typo.textArea : typo.margin;
1222
- const rects: TextRects = {
1223
- headline: { left: (W - typo.textWidth) / 2, top: 0, right: (W + typo.textWidth) / 2, bottom: 0 }
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;
1224
1860
  };
1225
- // Auto text colour from the background ACTUALLY behind the text block (after the panorama
1226
- // swap): sample the span gradient / screen background over the block. An explicit
1227
- // headlineColor wins; explicit (non-palette) backgrounds outside a span keep the white default.
1228
- const blockBottom = areaTop + typo.badgeRow + block.headlineHeight + block.gap + block.subHeight;
1229
- const samples: string[] = [];
1230
- for (const fx of [0.1, 0.5, 0.9]) {
1231
- for (const fy of [0, 0.5, 1]) {
1232
- const x = fx * W;
1233
- const y = areaTop + fy * (blockBottom - areaTop);
1234
- samples.push(inSpan ? samplePanorama(spanFills.get(inSpan.span[0])!, inSpan.k, W, x, y) : sampleBackground(r.background, W, H, x, y));
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
+ }
1235
1883
  }
1236
1884
  }
1237
- const headlineColor = screen.headlineColor ?? (inSpan || r.backgroundFromStyle ? readableTextOn(samples) : '#ffffff');
1238
- if (screen.badge?.trim()) {
1239
- const text = screen.badge.trim();
1885
+ if (r.badge && rects.badge) {
1886
+ const text = r.badge;
1240
1887
  if (text.length > BADGE_MAX_CHARS) {
1241
1888
  warnings.push({ screen: r.index, code: 'badge-long', message: `screen ${r.index + 1}: badge "${text}" is long — keep it to 1–3 words` });
1242
1889
  }
1243
1890
  const fontSize = typo.badgeFont;
1244
1891
  const pillH = fontSize * BADGE_HEIGHT;
1245
- const pillW = Math.min(typo.textWidth, estimateTextWidth(text, fontSize, typo.font) + 2 * BADGE_PAD_X * fontSize);
1246
- const cy = areaTop + pillH / 2;
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
+ }
1247
1917
  const pill = makeShapeLayer({
1248
1918
  shape: 'rectangle',
1249
1919
  left: W / 2,
@@ -1252,7 +1922,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1252
1922
  height: pillH,
1253
1923
  rx: pillH / 2,
1254
1924
  ry: pillH / 2,
1255
- fill: rgba(isHexColor(headlineColor) ? headlineColor : '#ffffff', 0.18),
1925
+ fill: pillFill,
1256
1926
  name: 'Badge'
1257
1927
  });
1258
1928
  const label = makeTextLayer({
@@ -1264,25 +1934,21 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1264
1934
  fontFamily: font,
1265
1935
  fontWeight: '700',
1266
1936
  lineHeight: 1,
1267
- fill: headlineColor,
1937
+ fill: labelColor,
1268
1938
  textAlign: 'center',
1269
1939
  name: 'Badge text',
1270
1940
  templateRole: 'editable',
1271
1941
  templateKey: 'badge'
1272
1942
  });
1273
1943
  layers.push(pill, label);
1274
- rects.badge = { left: W / 2 - pillW / 2, top: areaTop, right: W / 2 + pillW / 2, bottom: areaTop + pillH };
1275
1944
  }
1276
- const headlineTop = areaTop + typo.badgeRow;
1277
- rects.headline.top = headlineTop;
1278
- rects.headline.bottom = headlineTop + block.headlineHeight;
1279
1945
 
1280
1946
  // Center origin (editor convention): left/top are the box CENTER.
1281
1947
  layers.push(
1282
1948
  makeTextLayer({
1283
- text: screen.headline,
1949
+ text: g.text,
1284
1950
  left: W / 2,
1285
- top: headlineTop + block.headlineHeight / 2,
1951
+ top: g.headlineTop + block.headlineHeight / 2,
1286
1952
  width: typo.textWidth,
1287
1953
  fontSize: typo.headlineSize,
1288
1954
  fontFamily: font,
@@ -1296,30 +1962,25 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1296
1962
  })
1297
1963
  );
1298
1964
 
1299
- if (screen.subheadline?.trim()) {
1300
- const subTop = headlineTop + block.headlineHeight + block.gap;
1301
- const sub = makeTextLayer({
1302
- text: screen.subheadline,
1303
- left: W / 2,
1304
- top: subTop + block.subHeight / 2,
1305
- width: typo.textWidth,
1306
- fontSize: typo.subSize,
1307
- fontFamily: font,
1308
- fontWeight: '500',
1309
- lineHeight: SUBHEADLINE_LINE_HEIGHT,
1310
- fill: screen.subheadlineColor ?? headlineColor,
1311
- textAlign: 'center',
1312
- name: 'Subheadline',
1313
- templateRole: 'editable',
1314
- templateKey: 'subheadline'
1315
- });
1316
- // When it inherits the headline color, mute it slightly (standard Fabric `opacity`, editable
1317
- // in the editor). An explicit subheadlineColor is used as-is.
1318
- if (!screen.subheadlineColor) (sub.fabricData as Record<string, unknown>).opacity = SUBHEADLINE_OPACITY;
1319
- layers.push(sub);
1320
- rects.sub = { left: rects.headline.left, top: subTop, right: rects.headline.right, bottom: subTop + block.subHeight };
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
+ );
1321
1983
  }
1322
- textRectsByScreen.set(r.index, rects);
1323
1984
 
1324
1985
  const top = p.cy - p.boxHeight / 2;
1325
1986
  const bottom = p.cy + p.boxHeight / 2;
@@ -1338,9 +1999,16 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1338
1999
  mode: overshoot > 0 ? 'bleed' : 'clear',
1339
2000
  tangent: inTangentZone(overshoot, p.boxHeight, H),
1340
2001
  headlineSize: typo.headlineSize / W,
1341
- headlineTop: headlineTop / H,
2002
+ headlineTop: g.headlineTop / H,
1342
2003
  tilt: p.angle,
1343
- centerX: cx / W
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 } : {})
1344
2012
  });
1345
2013
 
1346
2014
  return makeScreen({
@@ -1357,27 +2025,34 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1357
2025
  // Copy + composition lint (warnings, never errors).
1358
2026
  resolved.forEach((r) => {
1359
2027
  const words = r.plan.headline.trim().split(/\s+/).filter(Boolean).length;
1360
- const n = r.index + 1;
2028
+ const nn = r.index + 1;
1361
2029
  if (words > COPY_RULES.headlineMaxWords) {
1362
- warnings.push({ screen: r.index, code: 'headline-words', message: `screen ${n}: headline has ${words} words (aim for 3–5) — cut, don't shrink` });
2030
+ warnings.push({ screen: r.index, code: 'headline-words', message: `screen ${nn}: headline has ${words} words (aim for 3–5) — cut, don't shrink` });
1363
2031
  }
1364
2032
  if (r.block.headlineLines > COPY_RULES.headlineMaxLines) {
1365
2033
  warnings.push({
1366
2034
  screen: r.index,
1367
2035
  code: 'headline-lines',
1368
- message: `screen ${n}: headline wraps to ~${r.block.headlineLines} lines at the set size (max 2) — it grows the text area for EVERY screen`
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`
1369
2044
  });
1370
2045
  }
1371
2046
  if (r.block.subLines > COPY_RULES.subheadlineMaxLines) {
1372
2047
  warnings.push({
1373
2048
  screen: r.index,
1374
2049
  code: 'subheadline-lines',
1375
- message: `screen ${n}: subheadline wraps to ~${r.block.subLines} lines (max 1) — shorten it or drop it`
2050
+ message: `screen ${nn}: subheadline wraps to ~${r.block.subLines} lines (max 1) — shorten it or drop it`
1376
2051
  });
1377
2052
  }
1378
2053
  const p = placements.get(r.index)!;
1379
2054
  if (p.zoom?.focusCropped) {
1380
- warnings.push({ screen: r.index, code: 'zoom-focus-cropped', message: `screen ${n}: the zoom card can't show the whole focus band — mark a tighter \`crop\`` });
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\`` });
1381
2056
  }
1382
2057
  });
1383
2058
  if (straddle.size > MAX_STRADDLES) {
@@ -1399,8 +2074,8 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1399
2074
  [s.into, s.cx - fr.W]
1400
2075
  ] as const) {
1401
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 };
1402
- const t = textRectsByScreen.get(screenIdx)!;
1403
- if ([t.headline, t.sub, t.badge].some((rect) => rect && rectsOverlap(rect, box))) {
2077
+ const t = geos[screenIdx].rects;
2078
+ if (textRectList(t).some((rect) => rectsOverlap(rect, box))) {
1404
2079
  warnings.push({ screen: screenIdx, code: 'panorama-seam', message: `screen ${screenIdx + 1}: the straddling device overlaps the text` });
1405
2080
  }
1406
2081
  }
@@ -1414,6 +2089,186 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1414
2089
  return { template: makeTemplate({ name: plan.name, screens, tags: ['generated'] }), report: { warnings, screens: metrics } };
1415
2090
  }
1416
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
+
1417
2272
  /**
1418
2273
  * Deterministically assemble a Template from a plan (see `composeSet` for the set-wide layout rules
1419
2274
  * and the lint report). Text layers are marked editable so the user can tweak them in the editor.