@appshoteditor/shot-dsl 0.4.0 → 0.5.1

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). */
@@ -269,16 +397,54 @@ const BADGE_MAX_CHARS = 28;
269
397
 
270
398
  // Frameless / zoom styling.
271
399
  const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel → a touch narrower)
272
- const FRAMELESS_RADIUS = 0.1; // × rendered width
400
+ const FRAMELESS_RADIUS = 0.1; // × rendered width — fallback, and the cap for `frameCornerRadiusRatio`
401
+ const FRAMELESS_RADIUS_MIN = 0.03; // × rendered width — floor for a device with squarer screen corners (e.g. iPad)
273
402
  const ZOOM_WIDTH = 0.88; // × W
274
403
  const ZOOM_RADIUS = 0.05; // × W
275
- const ZOOM_MAX_MAG = 2; // focus-derived zoom: at most 2× the full-width fit
276
404
  const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
277
405
  const SHADOW = { color: 'rgba(0,0,0,0.28)', blur: 0.07, offsetY: 0.025 }; // blur/offset × W
406
+ /** Device frames: `fabricData.deviceShadow` (canvas units) — the editor casts it from the screen area. */
407
+ const DEVICE_SHADOW = { blur: 0.075, offsetY: 0.03 };
408
+ const CALLOUT_SHADOW = { blur: 0.06, offsetY: 0.02 };
409
+ const MASCOT_SHADOW = { blur: 0.035, offsetY: 0.012 };
410
+
411
+ // Callouts (magnified selling element).
412
+ const CALLOUT_TARGET_WIDTH = 0.8; // × W — the card width aimed for
413
+ const CALLOUT_MARGIN = 0.035; // × W — side margin of the card
414
+ const CALLOUT_EDGE = 0.04; // × H — far-edge margin
415
+ const CALLOUT_TEXT_GAP = 0.02; // × H — gap under / over the text block
416
+ export const CALLOUT_MAG = { min: 1.6, max: 2.2, floor: 1.25 } as const;
417
+ /**
418
+ * A callout may cover its own source slice (the pop-out), but at most this share of the REST of the
419
+ * focus band — it is moved (up/down, out to a side edge) or made smaller, else skipped.
420
+ */
421
+ export const CALLOUT_MAX_FOCUS_COVER = 0.35;
422
+ const CALLOUT_RADIUS = 0.035; // × W
423
+ /**
424
+ * Padding added around the requested callout crop (screenshot px), as a multiple of the rounded
425
+ * mask's LOCAL corner radius (`CALLOUT_RADIUS * W`, converted to the crop's own pixel space — see
426
+ * `calloutGeometry`), so the rounded corners of the clip mask (`makeCalloutLayer`) don't cut into
427
+ * content at the requested crop's own corners (e.g. selection handles). The geometric minimum to
428
+ * keep a corner point inside a rounded corner of radius `rx` is `1 - 1/√2 ≈ 0.293`; the extra margin
429
+ * covers the fact that padding the crop shrinks the eventual magnification a little further.
430
+ */
431
+ const CALLOUT_CORNER_PAD = 0.35;
432
+ // Auto crop: fractions of the screenshot width (left-aligned — UI rows start at the left, so a cut
433
+ // at the right loses a chevron, not the first letters of a label); card h ÷ w.
434
+ const CALLOUT_AUTO = { x: 0.02, w: 0.64, aspect: 0.4 };
435
+ /** `callouts: "auto"` only derives a callout where the focus band is this tight (a specific selling element). */
436
+ export const CALLOUT_AUTO_MAX_FOCUS = 0.5;
437
+
438
+ // Mascot.
439
+ const MASCOT_SIZE = { hero: 0.3, screen: 0.22 }; // × W
440
+ const MASCOT_MARGIN = 0.02; // × W from the canvas edges
441
+ const MASCOT_PAD = 0.012; // × W clearance from text / callouts
442
+ const MASCOT_FOCUS_INSET = 0.1; // × W — the focus band's outer edges may be overlapped by this much
278
443
 
279
444
  // 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)
445
+ const STRADDLE_OVERLAP = 0.3; // × the device's visible width that crosses the seam (target)
446
+ /** 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). */
447
+ export const STRADDLE_MIN = 0.18;
282
448
  const MAX_STRADDLES = 1; // seam crossings per set before a lint warning
283
449
  const ORB_RADIUS = 0.34; // × W
284
450
  const ORB_Y = 0.7; // × H
@@ -307,6 +473,8 @@ const LAYOUTS: Record<ComposeLayout, LayoutSpec> = {
307
473
  */
308
474
  function charUnits(ch: string): number {
309
475
  if (ch === ' ') return 0.5;
476
+ // CJK / kana / Hangul / fullwidth glyphs are ~1 em: 1 / 0.66 ≈ 1.52 units, + headroom.
477
+ if (isWideChar(ch)) return WIDE_CHAR_UNITS;
310
478
  if ("iljI.,:;!|'’".includes(ch)) return 0.5;
311
479
  if ('ftr()[]-–'.includes(ch)) return 0.7;
312
480
  if ('mwMW'.includes(ch)) return 1.55;
@@ -314,14 +482,17 @@ function charUnits(ch: string): number {
314
482
  return 1;
315
483
  }
316
484
 
485
+ /** Width (in average-letter units) of a CJK / fullwidth glyph: ~1 em ÷ the 0.66 em unit, + headroom. */
486
+ const WIDE_CHAR_UNITS = 1.6;
487
+
317
488
  /** Estimated rendered width of `text` (single line) — the width model behind `estimateLines`. */
318
489
  export function estimateTextWidth(text: string, fontSize: number, metrics: TextMetrics = 'Inter'): number {
319
490
  const { charWidth, mono } = metricsOf(metrics);
320
- return [...text].reduce((sum, ch) => sum + (mono ? 1 : charUnits(ch)), 0) * fontSize * charWidth;
491
+ return [...text].reduce((sum, ch) => sum + (isWideChar(ch) ? WIDE_CHAR_UNITS : mono ? 1 : charUnits(ch)), 0) * fontSize * charWidth;
321
492
  }
322
493
 
323
494
  /**
324
- * Greedy word-wrap estimate (Fabric Textbox semantics: wrap at spaces, break over-long words) of how
495
+ * Greedy word-wrap estimate (Fabric Textbox semantics: wrap at spaces) of how
325
496
  * many lines `text` takes at `fontSize` in a box `width` wide, in the font `metrics` (a COMPOSE_FONTS
326
497
  * name, or a raw px-per-unit factor).
327
498
  */
@@ -329,7 +500,7 @@ export function estimateLines(text: string, fontSize: number, width: number, met
329
500
  const { charWidth, mono } = metricsOf(metrics);
330
501
  const unit = fontSize * charWidth;
331
502
  const maxUnits = Math.max(1, width / unit);
332
- const cu = (ch: string) => (mono ? 1 : charUnits(ch));
503
+ const cu = (ch: string) => (isWideChar(ch) ? WIDE_CHAR_UNITS : mono ? 1 : charUnits(ch));
333
504
  const units = (w: string) => [...w].reduce((sum, ch) => sum + cu(ch), 0);
334
505
  let lines = 0;
335
506
  for (const paragraph of text.split('\n')) {
@@ -345,7 +516,9 @@ export function estimateLines(text: string, fontSize: number, width: number, met
345
516
  lines++;
346
517
  current = len;
347
518
  }
348
- // A single word longer than a line wraps mid-word in Fabric's Textbox.
519
+ // A word wider than the line does NOT wrap in Fabric's Textbox (it widens the box to the
520
+ // word — see `overlongWords`; composeSet rejects such copy). Unspaced CJK runs are one
521
+ // "word" here; counting them as wrapping per line keeps this estimate conservative.
349
522
  while (current > maxUnits) {
350
523
  lines++;
351
524
  current -= maxUnits;
@@ -375,6 +548,9 @@ interface Typography {
375
548
  }
376
549
 
377
550
  interface TextBlock {
551
+ /** The headline as set (balanced / explicit breaks). */
552
+ headline: string[];
553
+ sub: string[];
378
554
  headlineLines: number;
379
555
  headlineHeight: number;
380
556
  subLines: number;
@@ -383,14 +559,23 @@ interface TextBlock {
383
559
  height: number;
384
560
  }
385
561
 
562
+ /** Lines of `text` at `size` in the box: explicit `\n` + balanced breaks (see typography.ts). */
563
+ function setLines(text: string, size: number, t: Pick<Typography, 'textWidth' | 'font'>): string[] {
564
+ // A line the balancer had to leave raw (an over-long word) still wraps in Fabric; measureTextBlock
565
+ // counts it with estimateLines.
566
+ return breakLines(text, t.textWidth, (line) => estimateTextWidth(line, size, t.font));
567
+ }
568
+
386
569
  function measureTextBlock(screen: ComposeScreenPlan, t: Omit<Typography, 'textArea'>): TextBlock {
387
- const headlineLines = estimateLines(screen.headline, t.headlineSize, t.textWidth, t.font);
570
+ const headline = setLines(screen.headline, t.headlineSize, t);
571
+ const headlineLines = headline.reduce((n, line) => n + estimateLines(line, t.headlineSize, t.textWidth, t.font), 0);
388
572
  const headlineHeight = headlineLines * t.headlineSize * HEADLINE_LINE_HEIGHT;
389
573
  const hasSub = !!screen.subheadline?.trim();
390
- const subLines = hasSub ? estimateLines(screen.subheadline!, t.subSize, t.textWidth, t.font) : 0;
574
+ const sub = hasSub ? setLines(screen.subheadline!, t.subSize, t) : [];
575
+ const subLines = sub.reduce((n, line) => n + estimateLines(line, t.subSize, t.textWidth, t.font), 0);
391
576
  const subHeight = subLines * t.subSize * SUBHEADLINE_LINE_HEIGHT;
392
577
  const gap = hasSub ? t.headlineSize * TEXT_GAP : 0;
393
- return { headlineLines, headlineHeight, subLines, subHeight, gap, height: t.badgeRow + headlineHeight + gap + subHeight };
578
+ return { headline, sub, headlineLines, headlineHeight, subLines, subHeight, gap, height: t.badgeRow + headlineHeight + gap + subHeight };
394
579
  }
395
580
 
396
581
  /** Everything decided per screen before layers are built. */
@@ -407,6 +592,14 @@ interface ResolvedScreen {
407
592
  typo: Typography;
408
593
  block: TextBlock;
409
594
  group: string;
595
+ role: ScreenRole;
596
+ /** Bleed preference of this screen's group (the hero defaults to `deep`). */
597
+ bleed: BleedPreference;
598
+ /** Badge text (hero badge or the screen's own). */
599
+ badge?: string;
600
+ /** Callout crop request (fractions), resolved from `callout` / `style.callouts` / rhythm. */
601
+ calloutReq?: ComposeCrop;
602
+ mascot?: ComposeMascot;
410
603
  }
411
604
 
412
605
  const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
@@ -438,10 +631,14 @@ function evenStops(colors: string[]): ColorStop[] {
438
631
 
439
632
  const linear = (colorStops: ColorStop[]): BackgroundJSON => ({ type: 'gradient', gradient: { type: 'linear', colorStops } });
440
633
 
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 {
634
+ /** Background from the plan-level palette (null when there is none / it's unusable). `step`: tonal step. */
635
+ function paletteBackground(palette: ComposePalette | undefined, index: number, step = index): BackgroundJSON | null {
443
636
  const colors = (palette?.colors ?? []).filter(isHexColor);
444
637
  if (!palette || colors.length === 0) return null;
638
+ if (palette.mode === 'tonal') {
639
+ const tone = (COMPOSE_PALETTE_TONES as readonly string[]).includes(palette.tone ?? '') ? palette.tone! : 'vivid';
640
+ return tonalBackground(colors[0], tone, step);
641
+ }
445
642
  if (palette.mode === 'sequence') {
446
643
  const c = colors[index % colors.length];
447
644
  return linear([
@@ -560,9 +757,9 @@ function nearStart(t: Typography): number {
560
757
 
561
758
  /**
562
759
  * 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`).
760
+ * region. An explicit `crop` keeps its full width (the model chose it); a focus-derived band always
761
+ * shows the full screenshot width (B1). The height always covers the requested band when the image
762
+ * allows (else `focusCropped`).
566
763
  */
567
764
  function fitCrop(
568
765
  shot: { width: number; height: number },
@@ -570,7 +767,9 @@ function fitCrop(
570
767
  A: number,
571
768
  explicit: boolean
572
769
  ): { cropX: number; cropY: number; cropW: number; cropH: number; focusCropped: boolean } {
573
- const minW = explicit ? req.w : shot.width / ZOOM_MAX_MAG;
770
+ // B1 (0.5.0): only an explicit `crop` narrows the card; a focus-derived zoom shows the FULL
771
+ // screenshot width (a half-width centre crop cut words off at both edges).
772
+ const minW = explicit ? req.w : shot.width;
574
773
  let w = Math.min(shot.width, Math.max(minW, req.h / A));
575
774
  let h = w * A;
576
775
  if (h > shot.height) {
@@ -839,20 +1038,40 @@ function placeGroup(
839
1038
  return out;
840
1039
  }
841
1040
 
842
- function shadowFor(W: number, scale: number) {
1041
+ /** Set-wide look shared by every layer builder. */
1042
+ interface Look {
1043
+ shadows: boolean;
1044
+ /** rgba() shadow colour (a deep tint of the brand hue for tonal palettes). */
1045
+ shadowColor: string;
1046
+ }
1047
+
1048
+ function shadowFor(W: number, scale: number, look: Look, spec: { blur: number; offsetY: number } = SHADOW) {
843
1049
  // Fabric scales shadow blur/offset by the object's scale (the editor's shadow controls use the
844
1050
  // 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 };
1051
+ return { color: look.shadowColor, blur: (spec.blur * W) / scale, offsetX: 0, offsetY: (spec.offsetY * W) / scale };
1052
+ }
1053
+
1054
+ /**
1055
+ * Non-zoom frameless corner radius, as a ratio of the rendered width: the device's own screen corner
1056
+ * radius (relative to its screen width), clamped to [FRAMELESS_RADIUS_MIN, FRAMELESS_RADIUS]. A flat
1057
+ * FRAMELESS_RADIUS reads fine on an iPhone (its screen corners are nearly that round) but is far too
1058
+ * round on an iPad (squarer corners, wide screen) — round enough to eat into the status bar. Falls
1059
+ * back to FRAMELESS_RADIUS when the device is unknown.
1060
+ */
1061
+ function frameCornerRadiusRatio(deviceId: string): number {
1062
+ const device = getDeviceFrame(deviceId);
1063
+ if (!device) return FRAMELESS_RADIUS;
1064
+ return Math.min(FRAMELESS_RADIUS, Math.max(FRAMELESS_RADIUS_MIN, device.cornerRadius / device.screenBounds.width));
846
1065
  }
847
1066
 
848
1067
  /** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
849
- function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement): LayerJSON {
1068
+ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look): LayerJSON {
850
1069
  const id = generateLayerId();
851
1070
  const shot = r.plan.screenshot;
852
1071
  const zoom = p.zoom;
853
1072
  const width = zoom ? zoom.cropW : shot.width;
854
1073
  const height = zoom ? zoom.cropH : shot.height;
855
- const radius = zoom ? ZOOM_RADIUS * r.W : FRAMELESS_RADIUS * width * p.scale;
1074
+ const radius = zoom ? ZOOM_RADIUS * r.W : frameCornerRadiusRatio(r.plan.deviceId) * width * p.scale;
856
1075
  const fabricData: Record<string, unknown> = {
857
1076
  type: 'image',
858
1077
  src: shot.url,
@@ -879,10 +1098,10 @@ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement): LayerJSON {
879
1098
  originY: 'center'
880
1099
  },
881
1100
  imageCornerRadius: radius,
882
- shadow: shadowFor(r.W, p.scale),
883
1101
  layerId: id,
884
1102
  layerType: 'image'
885
1103
  };
1104
+ if (look.shadows) fabricData.shadow = shadowFor(r.W, p.scale, look);
886
1105
  if (zoom) {
887
1106
  fabricData.cropX = zoom.cropX;
888
1107
  fabricData.cropY = zoom.cropY;
@@ -898,11 +1117,11 @@ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement): LayerJSON {
898
1117
  };
899
1118
  }
900
1119
 
901
- function makeSubjectLayer(r: ResolvedScreen, p: Placement, cx = p.cx, name?: string): LayerJSON {
1120
+ function makeSubjectLayer(r: ResolvedScreen, p: Placement, look: Look, cx = p.cx, name?: string): LayerJSON {
902
1121
  if (r.presentation === 'device') {
903
1122
  // One device layer; the screenshot rides on it as `fabricData.screenshot` and the editor
904
1123
  // places + clips it under the frame on import (see DeviceScreenshotJSON).
905
- return makeDeviceFrameLayer({
1124
+ const layer = makeDeviceFrameLayer({
906
1125
  deviceId: r.plan.deviceId,
907
1126
  screenshotUrl: r.plan.screenshot.url,
908
1127
  screenshotWidth: r.plan.screenshot.width,
@@ -915,8 +1134,20 @@ function makeSubjectLayer(r: ResolvedScreen, p: Placement, cx = p.cx, name?: str
915
1134
  angle: p.angle,
916
1135
  name
917
1136
  });
1137
+ // 0.5.0: the frame's drop shadow, in CANVAS units. The editor casts it from the screenshot
1138
+ // (clipped to the screen shape) rather than from the frame PNG, whose transparent screen hole
1139
+ // would otherwise let the bezel's shadow fall onto the screen. Older editors ignore it.
1140
+ if (look.shadows) {
1141
+ (layer.fabricData as Record<string, unknown>).deviceShadow = {
1142
+ color: look.shadowColor,
1143
+ blur: DEVICE_SHADOW.blur * r.W,
1144
+ offsetX: 0,
1145
+ offsetY: DEVICE_SHADOW.offsetY * r.W
1146
+ };
1147
+ }
1148
+ return layer;
918
1149
  }
919
- const layer = makeScreenshotImageLayer(r, p);
1150
+ const layer = makeScreenshotImageLayer(r, p, look);
920
1151
  (layer.fabricData as Record<string, unknown>).left = cx;
921
1152
  if (name) layer.name = name;
922
1153
  return layer;
@@ -930,11 +1161,15 @@ function focusPolygon(r: ResolvedScreen, p: Placement, cx: number) {
930
1161
  }
931
1162
 
932
1163
  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 };
1164
+ badge?: Rect;
1165
+ headline: Rect;
1166
+ /** One box per headline line (estimated width — conservative, i.e. never narrower than rendered). */
1167
+ lines: Rect[];
1168
+ sub?: Rect;
936
1169
  }
937
1170
 
1171
+ const textRectList = (t: TextRects): Rect[] => [t.badge, t.headline, t.sub].filter((x): x is Rect => !!x);
1172
+
938
1173
  function validateSpans(plan: ComposePlan, dims: Array<{ W: number; H: number }>): number[][] {
939
1174
  const spans = plan.style?.panorama?.spans ?? [];
940
1175
  const seen = new Set<number>();
@@ -959,11 +1194,42 @@ function validateSpans(plan: ComposePlan, dims: Array<{ W: number; H: number }>)
959
1194
  return spans;
960
1195
  }
961
1196
 
1197
+ function normalizeCrop(c: ComposeCrop | undefined | false): ComposeCrop | undefined {
1198
+ if (!c || typeof c !== 'object') return undefined;
1199
+ const x = clamp01(c.x);
1200
+ const y = clamp01(c.y);
1201
+ const w = Math.min(1 - x, clamp01(c.w));
1202
+ const h = Math.min(1 - y, clamp01(c.h));
1203
+ return [c.x, c.y, c.w, c.h].every(Number.isFinite) && w > 0 && h > 0 ? { x, y, w, h } : undefined;
1204
+ }
1205
+
1206
+ /**
1207
+ * Auto callout (`style.callouts: "auto"`): a left-aligned slice at the top of the focus band — only on
1208
+ * "selling" screens, i.e. where the band is tight (≤ CALLOUT_AUTO_MAX_FOCUS); a loose band says the
1209
+ * whole screen sells, and magnifying a blind slice of it adds noise. `force` (a `callout` accent)
1210
+ * skips that test.
1211
+ */
1212
+ function autoCallout(screen: ComposeScreenPlan, force = false): ComposeCrop | undefined {
1213
+ const focus = normalizeFocus(screen.focus);
1214
+ if (!focus || (!force && focus.bottom - focus.top > CALLOUT_AUTO_MAX_FOCUS + 1e-9)) return undefined;
1215
+ const { width: sw, height: sh } = screen.screenshot;
1216
+ const h = Math.min(focus.bottom - focus.top, (CALLOUT_AUTO.w * sw * CALLOUT_AUTO.aspect) / sh);
1217
+ const y = Math.min(1 - h, focus.top + (focus.bottom - focus.top - h) * 0.15);
1218
+ return { x: CALLOUT_AUTO.x, y, w: CALLOUT_AUTO.w, h };
1219
+ }
1220
+
1221
+ const HEX_TEXT_FALLBACK = '#ffffff';
1222
+
962
1223
  /**
963
1224
  * Two-pass SET layout: measure every text block → one type size + a text area reserved for the
964
1225
  * 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.
1226
+ * subject, layout, tilt, role) → the no-tangent bleed rule (respecting `focus`) → per-screen layers.
966
1227
  * Copy length never moves or resizes anything; copy-rule violations come back as `report.warnings`.
1228
+ *
1229
+ * 0.5.0 art direction on top of that system: balanced line breaks, a hero screen (larger type,
1230
+ * deeper bleed, optional mascot + badge), an accent rhythm, a tonal brand palette with ONE text
1231
+ * colour per set (WCAG ≥ 4.5 everywhere), magnified callouts, mascots, shadows and continuous
1232
+ * panorama motifs. Non-accent screens still share one type size, text area, scale and baseline.
967
1233
  */
968
1234
  export function composeSet(plan: ComposePlan): { template: Template; report: ComposeReport } {
969
1235
  const style = plan.style ?? {};
@@ -971,6 +1237,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
971
1237
  const bleedPref: BleedPreference = (COMPOSE_BLEEDS as readonly string[]).includes(style.bleed ?? '') ? style.bleed! : 'auto';
972
1238
  const font = style.font && COMPOSE_FONTS.includes(style.font) ? style.font : 'Inter';
973
1239
  const tiltScreens = new Set(style.tiltScreens ?? []);
1240
+ const n = plan.screens.length;
974
1241
 
975
1242
  // Per-screen canvas dims: honor explicit plan-level dims (backward compatible), else derive from
976
1243
  // the screen's device class so a mixed-device plan gets the correct aspect per screen.
@@ -986,18 +1253,35 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
986
1253
  const spanOf = new Map<number, { span: number[]; k: number }>();
987
1254
  for (const span of spans) span.forEach((idx, k) => spanOf.set(idx, { span, k }));
988
1255
 
989
- // Pass 1: set typography per canvas size (tallest text block wins).
990
- const typoByDims = new Map<string, Typography>();
1256
+ // Brand art (mascots): uploaded-asset URLs only, like screenshots.
1257
+ const art = new Map<string, ComposeArt>();
1258
+ for (const a of plan.art ?? []) {
1259
+ if (!a || typeof a.id !== 'string' || !a.id) throw new Error('art entries need an id');
1260
+ if (!isUploadedScreenshotSrc(a.url)) throw new Error(`art "${a.id}": url must be an uploaded asset (/api/screenshots/<id>/raw)`);
1261
+ if (!(a.width > 0 && a.height > 0)) throw new Error(`art "${a.id}": width/height must be positive`);
1262
+ art.set(a.id, a);
1263
+ }
1264
+
1265
+ // Roles: the hero (default screen 1) and the accent rhythm.
1266
+ const heroCfg: ComposeHero | null = style.hero === false ? null : style.hero && typeof style.hero === 'object' ? style.hero : {};
1267
+ const heroIndex = heroCfg && n > 0 ? Math.min(n - 1, Math.max(0, Math.floor(Number.isFinite(heroCfg.screen) ? heroCfg.screen! : 0))) : -1;
1268
+ const rhythm =
1269
+ style.rhythm && Number.isFinite(style.rhythm.every)
1270
+ ? { every: Math.min(6, Math.max(3, Math.round(style.rhythm.every))), treatment: style.rhythm.treatment === 'callout' ? 'callout' : 'text-bottom' }
1271
+ : null;
1272
+ const roleOf = (i: number): ScreenRole => (i === heroIndex ? 'hero' : rhythm && i % rhythm.every === rhythm.every - 1 ? 'accent' : 'set');
1273
+ const heroBadge = heroCfg?.badge?.trim() || undefined;
1274
+ const badgeOf = (i: number) => (i === heroIndex ? (heroBadge ?? plan.screens[i].badge?.trim()) : plan.screens[i].badge?.trim()) || undefined;
1275
+
1276
+ // Pass 1: set typography per canvas size (tallest text block wins); the hero gets its own scale.
1277
+ const typoByKey = new Map<string, Typography>();
991
1278
  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;
1279
+ const makeTypo = (i: number, scale: number, hasBadge: boolean): Typography => {
995
1280
  const { W, H } = dims[i];
996
1281
  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, {
1282
+ const headlineSize = unit * HEADLINE_SIZE * scale;
1283
+ const badgeFont = unit * HEADLINE_SIZE * BADGE_FONT;
1284
+ return {
1001
1285
  W,
1002
1286
  H,
1003
1287
  unit,
@@ -1008,13 +1292,55 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1008
1292
  deviceGap: unit * DEVICE_GAP,
1009
1293
  badgeFont,
1010
1294
  font,
1011
- badgeRow: hasBadge ? badgeFont * BADGE_HEIGHT + headlineSize * BADGE_GAP : 0,
1295
+ badgeRow: hasBadge ? badgeFont * BADGE_HEIGHT + unit * HEADLINE_SIZE * BADGE_GAP : 0,
1012
1296
  textArea: 0
1013
- });
1014
- });
1297
+ };
1298
+ };
1299
+ const typoFor = (i: number): Typography => {
1300
+ const hero = i === heroIndex;
1301
+ const key = `${dimsKey(dims[i])}|${hero ? 'hero' : 'set'}`;
1302
+ const cached = typoByKey.get(key);
1303
+ if (cached) return cached;
1304
+ let typo: Typography;
1305
+ if (hero) {
1306
+ // Hero scale: as large as asked, but it never adds a headline line (nor makes a word wider
1307
+ // than the box) — it steps down in 0.05s to 1× instead.
1308
+ const want = Math.min(HERO_SCALE_RANGE[1], Math.max(HERO_SCALE_RANGE[0], Number.isFinite(heroCfg?.scale) ? heroCfg!.scale! : HERO_SCALE));
1309
+ const hasBadge = !!badgeOf(i);
1310
+ const base = measureTextBlock(plan.screens[i], makeTypo(i, 1, hasBadge)).headlineLines;
1311
+ typo = makeTypo(i, 1, hasBadge);
1312
+ for (let s = want; s >= 1 - 1e-9; s -= 0.05) {
1313
+ const t = makeTypo(i, s, hasBadge);
1314
+ const fits = overlongWords(plan.screens[i].headline, t.textWidth, (w) => estimateTextWidth(w, t.headlineSize, t.font)).length === 0;
1315
+ if (fits && measureTextBlock(plan.screens[i], t).headlineLines <= base) {
1316
+ typo = t;
1317
+ break;
1318
+ }
1319
+ }
1320
+ } else {
1321
+ const hasBadge = plan.screens.some((_, j) => j !== heroIndex && dimsKey(dims[j]) === dimsKey(dims[i]) && !!badgeOf(j));
1322
+ typo = makeTypo(i, 1, hasBadge);
1323
+ }
1324
+ typoByKey.set(key, typo);
1325
+ return typo;
1326
+ };
1015
1327
 
1328
+ const calloutsAuto = style.callouts === 'auto';
1016
1329
  const resolved: ResolvedScreen[] = plan.screens.map((screen, i) => {
1017
- const typo = typoByDims.get(dimsKey(dims[i]))!;
1330
+ const role = roleOf(i);
1331
+ const typo = typoFor(i);
1332
+ // Fabric never breaks inside a word: it widens the Textbox past its margins. Reject instead.
1333
+ for (const [what, text, size] of [
1334
+ ['headline', screen.headline, typo.headlineSize],
1335
+ ['subheadline', screen.subheadline ?? '', typo.subSize]
1336
+ ] as const) {
1337
+ const long = overlongWords(text, typo.textWidth, (w) => estimateTextWidth(w, size, typo.font));
1338
+ if (long.length) {
1339
+ throw new Error(
1340
+ `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`
1341
+ );
1342
+ }
1343
+ }
1018
1344
  const block = measureTextBlock(screen, typo);
1019
1345
  typo.textArea = Math.max(typo.textArea, block.height);
1020
1346
  const presentation: ComposePresentation = (COMPOSE_PRESENTATIONS as readonly string[]).includes(screen.presentation ?? '')
@@ -1023,10 +1349,19 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1023
1349
  ? style.presentation!
1024
1350
  : 'device';
1025
1351
  let layout: ComposeLayout = screen.layout && LAYOUTS[screen.layout] ? screen.layout : 'text-top';
1352
+ let bleed: BleedPreference = bleedPref;
1353
+ let rawTilt = typeof screen.tilt === 'number' ? screen.tilt : tiltScreens.has(i) ? (style.tilt ?? 0) : 0;
1354
+ if (role === 'hero' && heroCfg) {
1355
+ if (heroCfg.layout && LAYOUTS[heroCfg.layout]) layout = heroCfg.layout;
1356
+ bleed = (COMPOSE_BLEEDS as readonly string[]).includes(heroCfg.bleed ?? '') ? heroCfg.bleed! : bleedPref === 'none' ? 'none' : 'deep';
1357
+ if (typeof heroCfg.tilt === 'number') rawTilt = heroCfg.tilt;
1358
+ } else if (role === 'accent' && rhythm) {
1359
+ if (rhythm.treatment === 'text-bottom') layout = 'text-bottom';
1360
+ else if (bleedPref !== 'none') bleed = 'deep';
1361
+ }
1026
1362
  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
1363
  const tilt = Number.isFinite(rawTilt) ? Math.max(-30, Math.min(30, rawTilt)) : 0;
1029
- const fromPalette = screen.background ? null : paletteBackground(style.palette, i);
1364
+ const fromPalette = screen.background ? null : paletteBackground(style.palette, i, role === 'hero' ? 0 : i);
1030
1365
  const background = screen.background ?? fromPalette ?? { type: 'solid', color: '#1F2937' };
1031
1366
  const backgroundFromStyle = !screen.background;
1032
1367
  const subjectKey =
@@ -1035,7 +1370,14 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1035
1370
  : presentation === 'frameless'
1036
1371
  ? `shot:${screen.screenshot.width}x${screen.screenshot.height}`
1037
1372
  : 'card';
1038
- const group = `${dims[i].W}x${dims[i].H}|${presentation}|${subjectKey}|${layout}|tilt:${tilt}`;
1373
+ const group = `${dims[i].W}x${dims[i].H}|${presentation}|${subjectKey}|${layout}|tilt:${tilt}|${role}|bleed:${bleed}`;
1374
+ // Callout: explicit crop, else auto (style.callouts / a `callout` accent) from the focus band.
1375
+ const explicitCallout = normalizeCrop(screen.callout);
1376
+ const accentCallout = role === 'accent' && rhythm?.treatment === 'callout';
1377
+ const wantsAuto = screen.callout !== false && !explicitCallout && (accentCallout || (calloutsAuto && role !== 'hero'));
1378
+ const calloutReq = presentation === 'zoom' ? undefined : (explicitCallout ?? (wantsAuto ? autoCallout(screen, accentCallout) : undefined));
1379
+ const mascot = (role === 'hero' ? (heroCfg?.mascot ?? screen.mascot) : screen.mascot) || undefined;
1380
+ if (mascot && !art.has(mascot.art)) throw new Error(`screen ${i + 1}: mascot.art "${mascot.art}" is not in plan.art`);
1039
1381
  return {
1040
1382
  index: i,
1041
1383
  plan: screen,
@@ -1048,7 +1390,12 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1048
1390
  backgroundFromStyle,
1049
1391
  typo,
1050
1392
  block,
1051
- group
1393
+ group,
1394
+ role,
1395
+ bleed,
1396
+ badge: badgeOf(i),
1397
+ calloutReq,
1398
+ mascot
1052
1399
  };
1053
1400
  });
1054
1401
 
@@ -1068,7 +1415,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1068
1415
  const placements = new Map<number, Placement>();
1069
1416
  for (const members of groups.values()) {
1070
1417
  const asStraddle = members.every((m) => wantsStraddle.has(m.index));
1071
- for (const [i, p] of placeGroup(members, bleedPref, warnings, asStraddle)) placements.set(i, p);
1418
+ for (const [i, p] of placeGroup(members, members[0].bleed, warnings, asStraddle)) placements.set(i, p);
1072
1419
  }
1073
1420
 
1074
1421
  // Panorama: straddling devices (first screen of a span) cross into the next screen.
@@ -1088,7 +1435,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1088
1435
  warnings.push({
1089
1436
  screen: i,
1090
1437
  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)`
1438
+ 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
1439
  });
1093
1440
  continue;
1094
1441
  }
@@ -1103,12 +1450,113 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1103
1450
  }
1104
1451
  }
1105
1452
 
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) {
1453
+ // ---- Palette: ONE text colour per set (B2), WCAG ≥ 4.5 on every style-derived background. ----
1454
+ const paletteColors = (style.palette?.colors ?? []).filter(isHexColor);
1455
+ const tonal = style.palette?.mode === 'tonal' && paletteColors.length > 0;
1456
+ const brandBase = tonal ? paletteColors[0] : null;
1457
+ const accentColor = tonal ? (paletteColors[1] ?? null) : null;
1458
+ const candidates = textCandidates(brandBase);
1459
+
1460
+ /** What a panorama span paints, given each screen's (possibly shifted) background. */
1461
+ const spanFill = (span: number[], bgOf: (r: ResolvedScreen) => BackgroundJSON): SpanFill => {
1109
1462
  const r0 = resolved[span[0]];
1110
- spanFills.set(span[0], spanFillFor(r0.background, span[0], span.length, r0.W, r0.H));
1463
+ const N = span.length;
1464
+ if (tonal && span.every((i) => resolved[i].backgroundFromStyle)) {
1465
+ // Tonal scene: one flowing ramp through every member's tone, corner to corner.
1466
+ 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) }));
1467
+ return { kind: 'linear', stops, coords: { x1: 0, y1: r0.H, x2: N * r0.W, y2: 0 } };
1468
+ }
1469
+ return spanFillFor(bgOf(r0), span[0], N, r0.W, r0.H);
1470
+ };
1471
+ const samplesOf = (r: ResolvedScreen, bgOf: (r: ResolvedScreen) => BackgroundJSON): string[] => {
1472
+ const out: string[] = [];
1473
+ const inSpan = spanOf.get(r.index);
1474
+ const fill = inSpan ? spanFill(inSpan.span, bgOf) : null;
1475
+ const bg = bgOf(r);
1476
+ for (let gx = 0; gx <= 4; gx++) {
1477
+ for (let gy = 0; gy <= 6; gy++) {
1478
+ const x = (gx / 4) * r.W;
1479
+ const y = (gy / 6) * r.H;
1480
+ out.push(fill ? samplePanorama(fill, inSpan!.k, r.W, x, y) : sampleBackground(bg, r.W, r.H, x, y));
1481
+ }
1482
+ }
1483
+ return out;
1484
+ };
1485
+ // M1: EVERY screen without an explicit headlineColor takes part in choosing the set colour.
1486
+ // Palette backgrounds (and spans whose fill comes from the palette) may be re-toned until it
1487
+ // passes; explicit backgrounds — including a panorama span whose fill is explicit — are sampled as
1488
+ // they are (never recoloured). When the set colour can't reach AA on such a FIXED unit, that unit
1489
+ // gets its own readable colour: a lone screen, or a whole span (one colour per span, so the scene
1490
+ // stays consistent) — metrics `textColorSource: "screen"`.
1491
+ const needsAuto = (r: ResolvedScreen) => !r.plan.headlineColor;
1492
+ /** Is this screen's visible background re-tonable (palette-derived)? A span follows its fill's source. */
1493
+ const shiftable = (r: ResolvedScreen) => {
1494
+ const inSpan = spanOf.get(r.index);
1495
+ return inSpan ? resolved[inSpan.span[0]].backgroundFromStyle : r.backgroundFromStyle;
1496
+ };
1497
+ const autoScreens = resolved.filter((r) => needsAuto(r) && shiftable(r));
1498
+ /** Fixed units: each explicit lone screen, and each span with an explicit fill (as one unit). */
1499
+ const fixedUnits: ResolvedScreen[][] = [];
1500
+ for (const r of resolved) {
1501
+ if (!needsAuto(r) || shiftable(r)) continue;
1502
+ const inSpan = spanOf.get(r.index);
1503
+ const unit = inSpan ? fixedUnits.find((u) => spanOf.get(u[0].index)?.span === inSpan.span) : undefined;
1504
+ if (unit) unit.push(r);
1505
+ else fixedUnits.push([r]);
1111
1506
  }
1507
+ const fixedSamples = fixedUnits.flat().flatMap((r) => samplesOf(r, (x) => x.background));
1508
+ let setText = HEX_TEXT_FALLBACK;
1509
+ if (autoScreens.length || fixedUnits.length) {
1510
+ const shifted = (target: string, shift: number) => (r: ResolvedScreen) => (r.backgroundFromStyle ? shiftBackground(r.background, target, shift) : r.background);
1511
+ // An explicit-only set keeps 0.4.0's white whenever white works (preferLight).
1512
+ const h = harmonize(
1513
+ autoScreens.length ? candidates : { dark: DARK_TEXT_FALLBACK, light: HEX_TEXT_FALLBACK },
1514
+ (target, shift) => autoScreens.flatMap((r) => samplesOf(r, shifted(target, shift))),
1515
+ fixedSamples,
1516
+ !autoScreens.length
1517
+ );
1518
+ setText = h.text;
1519
+ const target = h.text === candidates.dark ? '#FFFFFF' : '#000000';
1520
+ for (const r of resolved) if (r.backgroundFromStyle) r.background = shiftBackground(r.background, target, h.shift);
1521
+ }
1522
+ const bgNow = (r: ResolvedScreen) => r.background;
1523
+ /** Fixed units where the set colour can't reach AA: their own best colour (one per unit). */
1524
+ const ownText = new Map<number, string>();
1525
+ for (const unit of fixedUnits) {
1526
+ const samples = unit.flatMap((r) => samplesOf(r, bgNow));
1527
+ if (worstContrast(setText, samples) >= MIN_CONTRAST) continue;
1528
+ const options = [setText, candidates.dark, candidates.light, HEX_TEXT_FALLBACK, DARK_TEXT_FALLBACK].filter(isHexColor);
1529
+ const best = options.sort((a, b) => worstContrast(b, samples) - worstContrast(a, samples))[0];
1530
+ for (const r of unit) ownText.set(r.index, best);
1531
+ }
1532
+ const textColorOf = (r: ResolvedScreen) => r.plan.headlineColor ?? ownText.get(r.index) ?? setText;
1533
+ const textSourceOf = (r: ResolvedScreen): 'set' | 'screen' | 'plan' => (r.plan.headlineColor ? 'plan' : ownText.has(r.index) ? 'screen' : 'set');
1534
+ const contrastOf = new Map<number, number>();
1535
+ const warnedContrast = new Set<number>();
1536
+ for (const r of resolved) {
1537
+ const worst = worstContrast(textColorOf(r), samplesOf(r, bgNow));
1538
+ contrastOf.set(r.index, worst);
1539
+ if (!r.plan.headlineColor && worst < MIN_CONTRAST) {
1540
+ warnedContrast.add(r.index);
1541
+ warnings.push({
1542
+ screen: r.index,
1543
+ code: 'contrast-low',
1544
+ 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`
1545
+ });
1546
+ }
1547
+ }
1548
+
1549
+ // The set's look: shadows tinted with the brand hue.
1550
+ const look: Look = {
1551
+ shadows: style.shadows !== false,
1552
+ shadowColor: brandBase
1553
+ ? rgba(hslToHex({ h: hexToHsl(brandBase).h, s: Math.min(0.8, hexToHsl(brandBase).s), l: 0.14 }), 0.34)
1554
+ : SHADOW.color
1555
+ };
1556
+
1557
+ // Span fills (validated up front, so a bad span background fails before any layer is built).
1558
+ const spanFills = new Map<number, SpanFill>();
1559
+ for (const span of spans) spanFills.set(span[0], spanFill(span, bgNow));
1112
1560
  /** Vertical band of a screen's reserved text block (with a small breathing gap). */
1113
1561
  const textBand = (r: ResolvedScreen) => {
1114
1562
  const top = r.layout === 'text-bottom' ? r.H - r.typo.margin - r.typo.textArea : r.typo.margin;
@@ -1143,12 +1591,200 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1143
1591
  return { cy, r };
1144
1592
  };
1145
1593
 
1146
- // Pass 3: layers per screen.
1594
+ // ---- Pass 3a: per-screen geometry (text boxes, callouts) — needed by neighbours' mascots. ----
1595
+ interface Geo {
1596
+ r: ResolvedScreen;
1597
+ p: Placement;
1598
+ cx: number;
1599
+ areaTop: number;
1600
+ headlineTop: number;
1601
+ rects: TextRects;
1602
+ text: string;
1603
+ subText?: string;
1604
+ callout?: { rect: Rect; mag: number; crop: { x: number; y: number; w: number; h: number }; requested: { x: number; y: number; w: number; h: number }; scale: number; focusCover: number };
1605
+ focusCore?: Rect;
1606
+ mascot?: { rect: Rect; scale: number; flip: boolean; art: ComposeArt; seamPartner?: number; dx?: number };
1607
+ }
1608
+ const geos: Geo[] = resolved.map((r) => {
1609
+ const { W, H, typo, block } = r;
1610
+ const p = placements.get(r.index)!;
1611
+ const cx = straddle.get(r.index)?.cx ?? p.cx;
1612
+ const areaTop = r.layout === 'text-bottom' ? H - typo.margin - typo.textArea : typo.margin;
1613
+ const headlineTop = areaTop + typo.badgeRow;
1614
+ const lh = typo.headlineSize * HEADLINE_LINE_HEIGHT;
1615
+ const rects: TextRects = {
1616
+ headline: { left: (W - typo.textWidth) / 2, top: headlineTop, right: (W + typo.textWidth) / 2, bottom: headlineTop + block.headlineHeight },
1617
+ lines: block.headline.map((line, k) => {
1618
+ const w = Math.min(typo.textWidth, estimateTextWidth(line, typo.headlineSize, typo.font));
1619
+ return { left: (W - w) / 2, top: headlineTop + k * lh, right: (W + w) / 2, bottom: headlineTop + (k + 1) * lh };
1620
+ })
1621
+ };
1622
+ if (r.badge) {
1623
+ const pillH = typo.badgeFont * BADGE_HEIGHT;
1624
+ const pillW = Math.min(typo.textWidth, estimateTextWidth(r.badge, typo.badgeFont, typo.font) + 2 * BADGE_PAD_X * typo.badgeFont);
1625
+ rects.badge = { left: W / 2 - pillW / 2, top: areaTop, right: W / 2 + pillW / 2, bottom: areaTop + pillH };
1626
+ }
1627
+ if (block.sub.length) {
1628
+ const subTop = headlineTop + block.headlineHeight + block.gap;
1629
+ rects.sub = { left: rects.headline.left, top: subTop, right: rects.headline.right, bottom: subTop + block.subHeight };
1630
+ }
1631
+ const poly = focusPolygon(r, p, cx);
1632
+ let focusCore: Rect | undefined;
1633
+ if (poly) {
1634
+ const xs = poly.map((pt) => pt.x);
1635
+ const ys = poly.map((pt) => pt.y);
1636
+ const inset = MASCOT_FOCUS_INSET * W;
1637
+ focusCore = { left: Math.min(...xs) + inset, right: Math.max(...xs) - inset, top: Math.min(...ys), bottom: Math.max(...ys) };
1638
+ }
1639
+ const geo: Geo = { r, p, cx, areaTop, headlineTop, rects, text: block.headline.join('\n'), subText: block.sub.length ? block.sub.join('\n') : undefined, focusCore };
1640
+ // Callouts never ride a seam-crossing device.
1641
+ if (r.calloutReq && !straddle.has(r.index)) geo.callout = calloutGeometry(r, p, cx, rects, warnings) ?? undefined;
1642
+ return geo;
1643
+ });
1644
+
1645
+ // ---- Pass 3b: mascots (placement avoids text, callouts and the focus band; may cross a seam). ----
1646
+ /** Mascots already placed that show on screen `idx` (its own, and seam halves crossing into it). */
1647
+ const mascotsOn = (idx: number): Rect[] =>
1648
+ geos.flatMap((o) => {
1649
+ if (!o.mascot) return [];
1650
+ if (o.r.index === idx) return [o.mascot.rect];
1651
+ 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! }];
1652
+ return [];
1653
+ });
1654
+ const obstaclesOf = (g: Geo): Rect[] => [
1655
+ ...textRectList(g.rects).filter((x) => x !== g.rects.headline),
1656
+ ...g.rects.lines,
1657
+ ...(g.callout ? [g.callout.rect] : []),
1658
+ // Low (review): mascots never overlap each other, including a neighbour's seam half.
1659
+ ...mascotsOn(g.r.index)
1660
+ ];
1661
+ for (const g of geos) {
1662
+ const m = g.r.mascot;
1663
+ if (!m) continue;
1664
+ const a = art.get(m.art)!;
1665
+ const { W, H } = g.r;
1666
+ const inSpan = spanOf.get(g.r.index);
1667
+ const partner = inSpan ? (inSpan.k < inSpan.span.length - 1 ? inSpan.span[inSpan.k + 1] : inSpan.span[inSpan.k - 1]) : undefined;
1668
+ const seamX = inSpan ? (inSpan.k < inSpan.span.length - 1 ? W : 0) : undefined;
1669
+ 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;
1670
+ const anchor: MascotAnchor = (MASCOT_ANCHORS as readonly string[]).includes(m.anchor ?? '') ? m.anchor! : 'headline';
1671
+ const order: MascotAnchor[] = [anchor, ...(['headline', 'device-top', 'device-side'] as MascotAnchor[]).filter((x) => x !== anchor)];
1672
+ const pad = MASCOT_PAD * W;
1673
+ const margin = MASCOT_MARGIN * W;
1674
+ const textRects = textRectList(g.rects);
1675
+ const textTop = Math.min(...textRects.map((t) => t.top));
1676
+ const textBottom = Math.max(...textRects.map((t) => t.bottom));
1677
+ 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 };
1678
+ const textTopLayout = g.r.layout !== 'text-bottom';
1679
+ let placed: Geo['mascot'] | undefined;
1680
+ const fits = (rect: Rect, crossing: boolean): boolean => {
1681
+ const bounds: Rect = { left: margin, top: 0.012 * H, right: W - margin, bottom: H - 0.012 * H };
1682
+ if (crossing) {
1683
+ if (rect.top < bounds.top || rect.bottom > bounds.bottom) return false;
1684
+ } else if (!insideRect(rect, bounds)) return false;
1685
+ const blocks = (geo: Geo, dx: number) => {
1686
+ const shiftedRect = { ...rect, left: rect.left + dx, right: rect.right + dx };
1687
+ if (obstaclesOf(geo).some((o) => overlaps(shiftedRect, o, pad))) return true;
1688
+ return !!geo.focusCore && geo.focusCore.right > geo.focusCore.left && overlaps(shiftedRect, geo.focusCore);
1689
+ };
1690
+ if (blocks(g, 0)) return false;
1691
+ if (crossing && partner !== undefined) {
1692
+ const pg = geos[partner];
1693
+ // The half on the partner screen must clear ITS text / callout / focus too.
1694
+ if (blocks(pg, seamX === W ? -W : W)) return false;
1695
+ }
1696
+ return true;
1697
+ };
1698
+ const tryAt = (list: Array<[number, number]>, w: number, h: number, crossing: boolean) => {
1699
+ for (const [x, y] of list) {
1700
+ const rect = rectOf(x, y, w, h);
1701
+ if (fits(rect, crossing)) return rect;
1702
+ }
1703
+ return null;
1704
+ };
1705
+ for (const k of [1, 0.85, 0.7]) {
1706
+ const w = size * k;
1707
+ const h = (w * a.height) / a.width;
1708
+ for (const which of anchor === 'seam' ? (['seam', ...order.slice(1)] as MascotAnchor[]) : order) {
1709
+ let rect: Rect | null = null;
1710
+ let crossing = false;
1711
+ if (which === 'seam') {
1712
+ if (seamX === undefined) continue;
1713
+ crossing = true;
1714
+ rect = tryAt(
1715
+ [0.78, 0.68, 0.58, 0.88, 0.48, 0.38].map((f) => [seamX, f * H] as [number, number]),
1716
+ w,
1717
+ h,
1718
+ true
1719
+ );
1720
+ } else if (which === 'headline') {
1721
+ const L = g.rects.lines;
1722
+ const last = L[L.length - 1];
1723
+ const first = L[0];
1724
+ const nearY = textTopLayout ? textBottom + pad + h / 2 : textTop - pad - h / 2;
1725
+ rect = tryAt(
1726
+ [
1727
+ [last.right + pad + w / 2, (last.top + last.bottom) / 2],
1728
+ [last.left - pad - w / 2, (last.top + last.bottom) / 2],
1729
+ [first.right + pad + w / 2, (first.top + first.bottom) / 2],
1730
+ [first.left - pad - w / 2, (first.top + first.bottom) / 2],
1731
+ [W - margin - w / 2, nearY],
1732
+ [margin + w / 2, nearY]
1733
+ ],
1734
+ w,
1735
+ h,
1736
+ false
1737
+ );
1738
+ } else if (which === 'device-top') {
1739
+ 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);
1740
+ rect = tryAt(
1741
+ [
1742
+ [Math.min(devBox.right - w * 0.35, W - margin - w / 2), y],
1743
+ [Math.max(devBox.left + w * 0.35, margin + w / 2), y]
1744
+ ],
1745
+ w,
1746
+ h,
1747
+ false
1748
+ );
1749
+ } else {
1750
+ const ys = textTopLayout ? [0.8, 0.7, 0.6, 0.5] : [0.2, 0.3, 0.4, 0.5];
1751
+ rect = tryAt(
1752
+ ys.flatMap((f) => [
1753
+ [W - margin - w / 2, f * H] as [number, number],
1754
+ [margin + w / 2, f * H] as [number, number]
1755
+ ]),
1756
+ w,
1757
+ h,
1758
+ false
1759
+ );
1760
+ }
1761
+ if (rect) {
1762
+ const cxm = (rect.left + rect.right) / 2;
1763
+ const faceLeft = cxm > W / 2; // face into the canvas
1764
+ const flip = typeof m.flip === 'boolean' ? m.flip : a.faces ? (faceLeft ? a.faces === 'right' : a.faces === 'left') : false;
1765
+ placed = { rect, scale: w / a.width, flip, art: a, ...(crossing ? { seamPartner: partner, dx: seamX === W ? -W : W } : {}) };
1766
+ break;
1767
+ }
1768
+ }
1769
+ if (placed) break;
1770
+ }
1771
+ if (placed) g.mascot = placed;
1772
+ else {
1773
+ warnings.push({
1774
+ screen: g.r.index,
1775
+ code: 'mascot-skipped',
1776
+ 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`
1777
+ });
1778
+ }
1779
+ }
1780
+
1781
+ // ---- Pass 3c: layers per screen (bottom → top). ----
1147
1782
  const metrics: ComposeScreenMetrics[] = [];
1148
- const textRectsByScreen = new Map<number, TextRects>();
1149
- const screens = resolved.map((r) => {
1783
+ const decoration: Motif = (MOTIFS as readonly string[]).includes(style.panorama?.decoration ?? '') ? style.panorama!.decoration! : 'orbs';
1784
+ const darkText = setText === candidates.dark || (!brandBase && setText === DARK_TEXT_FALLBACK);
1785
+ const screens = geos.map((g) => {
1786
+ const { r, p, cx, rects } = g;
1150
1787
  const { W, H, typo, block, plan: screen } = r;
1151
- const p = placements.get(r.index)!;
1152
1788
  const layers: LayerJSON[] = [];
1153
1789
  let background = r.background;
1154
1790
 
@@ -1157,9 +1793,9 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1157
1793
  if (inSpan) {
1158
1794
  const { span, k } = inSpan;
1159
1795
  const N = span.length;
1160
- // The span's FIRST screen's (already resolved) background runs across the whole span.
1796
+ // One continuous fill across the whole span, offset per screen.
1161
1797
  const fill = spanFills.get(span[0])!;
1162
- background = { type: 'solid', color: fill.kind === 'solid' ? fill.color : fill.stops[0].color };
1798
+ background = { type: 'solid', color: fill.kind === 'solid' ? fill.color : colorAt(fill.stops, rampT(fill.coords, k * W + W / 2, H / 2)) };
1163
1799
  const bg = makeShapeLayer({
1164
1800
  shape: 'rectangle',
1165
1801
  left: (N * W) / 2 - k * W,
@@ -1185,7 +1821,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1185
1821
  evented: false
1186
1822
  });
1187
1823
  layers.push(bg);
1188
- if ((style.panorama?.decoration ?? 'orbs') === 'orbs') {
1824
+ if (decoration === 'orbs') {
1189
1825
  // A soft orb centred on each seam touching this screen, kept clear of both text blocks.
1190
1826
  for (let seam = 1; seam < N; seam++) {
1191
1827
  if (seam !== k && seam !== k + 1) continue;
@@ -1202,6 +1838,10 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1202
1838
  })
1203
1839
  );
1204
1840
  }
1841
+ } else if (decoration !== 'none') {
1842
+ // H1: only the part of the span-wide motif this screen shows (O(N) per span, not O(N²)).
1843
+ const motif = motifPathForScreen(motifSubpaths(decoration, N * W, W, H), k, W, MOTIF_STROKE * W * 2);
1844
+ if (motif) layers.push(makeMotifLayer(motif, k, N, W, darkText, setText));
1205
1845
  }
1206
1846
  }
1207
1847
 
@@ -1210,40 +1850,93 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1210
1850
  if (s.into !== r.index) continue;
1211
1851
  const fr = resolved[from];
1212
1852
  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);
1853
+ layers.push(makeSubjectLayer(fr, fp, look, s.cx - W, `${getDeviceFrame(fr.plan.deviceId)?.name ?? 'Screenshot'} (continued)`));
1215
1854
  }
1216
1855
 
1217
- const cx = straddle.get(r.index)?.cx ?? p.cx;
1218
- layers.push(makeSubjectLayer(r, p, cx));
1856
+ layers.push(makeSubjectLayer(r, p, look, cx));
1857
+
1858
+ // Callout (magnified selling element) over the device, under the text.
1859
+ if (g.callout) layers.push(makeCalloutLayer(r, g.callout, look));
1860
+
1861
+ // Mascots: this screen's own, plus a neighbour's half that crosses the seam onto this screen.
1862
+ if (g.mascot) layers.push(makeMascotLayer(g.mascot, 0, W, look, 'Mascot'));
1863
+ for (const other of geos) {
1864
+ if (other.mascot?.seamPartner === r.index) layers.push(makeMascotLayer(other.mascot, other.mascot.dx!, W, look, 'Mascot (continued)'));
1865
+ }
1219
1866
 
1220
1867
  // 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 }
1868
+ const headlineColor = textColorOf(r);
1869
+ const hue = brandBase ?? accentColor;
1870
+ const subTint = hue && !screen.subheadlineColor && isHexColor(headlineColor) ? mixHex(headlineColor, hue, SUBHEADLINE_TINT) : null;
1871
+ // Dense samples right behind a text box (the whole-canvas grid can miss a gradient's contrast
1872
+ // minimum between its points).
1873
+ const textSamples = (rect: Rect) => {
1874
+ const out: string[] = [];
1875
+ for (let gx = 0; gx <= 8; gx++) {
1876
+ for (let gy = 0; gy <= 4; gy++) {
1877
+ const x = rect.left + ((rect.right - rect.left) * gx) / 8;
1878
+ const y = rect.top + ((rect.bottom - rect.top) * gy) / 4;
1879
+ out.push(inSpan ? samplePanorama(spanFills.get(inSpan.span[0])!, inSpan.k, W, x, y) : sampleBackground(r.background, W, H, x, y));
1880
+ }
1881
+ }
1882
+ return out;
1224
1883
  };
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));
1884
+ const subSamples = rects.sub ? textSamples(rects.sub) : [];
1885
+ // The tint must clear AA with a margin (SUBHEADLINE_TINT_MIN), so it never lands at 4.49.
1886
+ const subColor =
1887
+ screen.subheadlineColor ??
1888
+ (subTint && worstContrast(subTint, [...samplesOf(r, bgNow), ...subSamples]) >= SUBHEADLINE_TINT_MIN ? subTint : headlineColor);
1889
+ // Warn (never silently ship sub-AA text) when the colours actually used fall short right behind
1890
+ // the headline or the subheadline — once per screen.
1891
+ if (!warnedContrast.has(r.index)) {
1892
+ const checks: Array<[string, string, string[]]> = [];
1893
+ if (!screen.headlineColor && isHexColor(headlineColor)) checks.push(['headline', headlineColor, textSamples(rects.headline)]);
1894
+ if (rects.sub && !screen.subheadlineColor && isHexColor(subColor)) checks.push(['subheadline', subColor, subSamples]);
1895
+ for (const [what, color, samples] of checks) {
1896
+ const worst = worstContrast(color, samples);
1897
+ if (worst < MIN_CONTRAST) {
1898
+ warnedContrast.add(r.index);
1899
+ warnings.push({
1900
+ screen: r.index,
1901
+ code: 'contrast-low',
1902
+ 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`
1903
+ });
1904
+ break;
1905
+ }
1235
1906
  }
1236
1907
  }
1237
- const headlineColor = screen.headlineColor ?? (inSpan || r.backgroundFromStyle ? readableTextOn(samples) : '#ffffff');
1238
- if (screen.badge?.trim()) {
1239
- const text = screen.badge.trim();
1908
+ if (r.badge && rects.badge) {
1909
+ const text = r.badge;
1240
1910
  if (text.length > BADGE_MAX_CHARS) {
1241
1911
  warnings.push({ screen: r.index, code: 'badge-long', message: `screen ${r.index + 1}: badge "${text}" is long — keep it to 1–3 words` });
1242
1912
  }
1243
1913
  const fontSize = typo.badgeFont;
1244
1914
  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;
1915
+ const pillW = rects.badge.right - rects.badge.left;
1916
+ const cy = g.areaTop + pillH / 2;
1917
+ // Tonal: a solid pill in the accent (or the text colour), label in whichever passes best on it.
1918
+ const pillSolid = tonal ? (accentColor ?? (isHexColor(headlineColor) ? headlineColor : null)) : null;
1919
+ const labelColor = pillSolid
1920
+ ? [headlineColor, candidates.light, candidates.dark, '#FFFFFF', DARK_TEXT_FALLBACK]
1921
+ .filter(isHexColor)
1922
+ .sort((x, y) => contrastRatio(y, pillSolid) - contrastRatio(x, pillSolid))[0]
1923
+ : headlineColor;
1924
+ // The label must read at WCAG AA on the pill too (review Low): a solid pill is toned away
1925
+ // from the label until it does; a translucent one gets fainter (closer to the background,
1926
+ // on which the set colour already passes).
1927
+ let pillFill: string;
1928
+ if (pillSolid) {
1929
+ pillFill = pillSolid;
1930
+ // Toward whichever extreme contrasts more with the label (≥ 4.58:1 for any label colour).
1931
+ const away = contrastRatio(labelColor, '#000000') >= contrastRatio(labelColor, '#FFFFFF') ? '#000000' : '#FFFFFF';
1932
+ for (let t = 0.05; t <= 1 + 1e-9 && contrastRatio(labelColor, pillFill) < MIN_CONTRAST; t += 0.05) pillFill = mixHex(pillSolid, away, t);
1933
+ } else {
1934
+ const text = isHexColor(headlineColor) ? headlineColor : '#ffffff';
1935
+ const behind = inSpan ? samplePanorama(spanFills.get(inSpan.span[0])!, inSpan.k, W, W / 2, cy) : sampleBackground(r.background, W, H, W / 2, cy);
1936
+ let alpha = 0.18;
1937
+ while (alpha > 0.021 && contrastRatio(text, mixHex(behind, text, alpha)) < MIN_CONTRAST) alpha -= 0.02;
1938
+ pillFill = rgba(text, Math.round(alpha * 100) / 100);
1939
+ }
1247
1940
  const pill = makeShapeLayer({
1248
1941
  shape: 'rectangle',
1249
1942
  left: W / 2,
@@ -1252,7 +1945,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1252
1945
  height: pillH,
1253
1946
  rx: pillH / 2,
1254
1947
  ry: pillH / 2,
1255
- fill: rgba(isHexColor(headlineColor) ? headlineColor : '#ffffff', 0.18),
1948
+ fill: pillFill,
1256
1949
  name: 'Badge'
1257
1950
  });
1258
1951
  const label = makeTextLayer({
@@ -1264,25 +1957,21 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1264
1957
  fontFamily: font,
1265
1958
  fontWeight: '700',
1266
1959
  lineHeight: 1,
1267
- fill: headlineColor,
1960
+ fill: labelColor,
1268
1961
  textAlign: 'center',
1269
1962
  name: 'Badge text',
1270
1963
  templateRole: 'editable',
1271
1964
  templateKey: 'badge'
1272
1965
  });
1273
1966
  layers.push(pill, label);
1274
- rects.badge = { left: W / 2 - pillW / 2, top: areaTop, right: W / 2 + pillW / 2, bottom: areaTop + pillH };
1275
1967
  }
1276
- const headlineTop = areaTop + typo.badgeRow;
1277
- rects.headline.top = headlineTop;
1278
- rects.headline.bottom = headlineTop + block.headlineHeight;
1279
1968
 
1280
1969
  // Center origin (editor convention): left/top are the box CENTER.
1281
1970
  layers.push(
1282
1971
  makeTextLayer({
1283
- text: screen.headline,
1972
+ text: g.text,
1284
1973
  left: W / 2,
1285
- top: headlineTop + block.headlineHeight / 2,
1974
+ top: g.headlineTop + block.headlineHeight / 2,
1286
1975
  width: typo.textWidth,
1287
1976
  fontSize: typo.headlineSize,
1288
1977
  fontFamily: font,
@@ -1296,30 +1985,25 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1296
1985
  })
1297
1986
  );
1298
1987
 
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 };
1988
+ if (g.subText && rects.sub) {
1989
+ layers.push(
1990
+ makeTextLayer({
1991
+ text: g.subText,
1992
+ left: W / 2,
1993
+ top: rects.sub.top + block.subHeight / 2,
1994
+ width: typo.textWidth,
1995
+ fontSize: typo.subSize,
1996
+ fontFamily: font,
1997
+ fontWeight: SUBHEADLINE_WEIGHT,
1998
+ lineHeight: SUBHEADLINE_LINE_HEIGHT,
1999
+ fill: subColor,
2000
+ textAlign: 'center',
2001
+ name: 'Subheadline',
2002
+ templateRole: 'editable',
2003
+ templateKey: 'subheadline'
2004
+ })
2005
+ );
1321
2006
  }
1322
- textRectsByScreen.set(r.index, rects);
1323
2007
 
1324
2008
  const top = p.cy - p.boxHeight / 2;
1325
2009
  const bottom = p.cy + p.boxHeight / 2;
@@ -1338,9 +2022,16 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1338
2022
  mode: overshoot > 0 ? 'bleed' : 'clear',
1339
2023
  tangent: inTangentZone(overshoot, p.boxHeight, H),
1340
2024
  headlineSize: typo.headlineSize / W,
1341
- headlineTop: headlineTop / H,
2025
+ headlineTop: g.headlineTop / H,
1342
2026
  tilt: p.angle,
1343
- centerX: cx / W
2027
+ centerX: cx / W,
2028
+ role: r.role,
2029
+ headlineLines: block.headline,
2030
+ textColor: headlineColor,
2031
+ textColorSource: textSourceOf(r),
2032
+ contrast: contrastOf.get(r.index)!,
2033
+ ...(g.callout ? { callout: { ...g.callout.rect, magnification: g.callout.mag, focusCover: g.callout.focusCover } } : {}),
2034
+ ...(g.mascot ? { mascot: g.mascot.rect } : {})
1344
2035
  });
1345
2036
 
1346
2037
  return makeScreen({
@@ -1357,27 +2048,34 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1357
2048
  // Copy + composition lint (warnings, never errors).
1358
2049
  resolved.forEach((r) => {
1359
2050
  const words = r.plan.headline.trim().split(/\s+/).filter(Boolean).length;
1360
- const n = r.index + 1;
2051
+ const nn = r.index + 1;
1361
2052
  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` });
2053
+ warnings.push({ screen: r.index, code: 'headline-words', message: `screen ${nn}: headline has ${words} words (aim for 3–5) — cut, don't shrink` });
1363
2054
  }
1364
2055
  if (r.block.headlineLines > COPY_RULES.headlineMaxLines) {
1365
2056
  warnings.push({
1366
2057
  screen: r.index,
1367
2058
  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`
2059
+ message: `screen ${nn}: headline wraps to ~${r.block.headlineLines} lines at the set size (max 2) — it grows the text area for EVERY screen`
2060
+ });
2061
+ }
2062
+ if (hasOrphan(r.block.headline)) {
2063
+ warnings.push({
2064
+ screen: r.index,
2065
+ code: 'headline-orphan',
2066
+ 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
2067
  });
1370
2068
  }
1371
2069
  if (r.block.subLines > COPY_RULES.subheadlineMaxLines) {
1372
2070
  warnings.push({
1373
2071
  screen: r.index,
1374
2072
  code: 'subheadline-lines',
1375
- message: `screen ${n}: subheadline wraps to ~${r.block.subLines} lines (max 1) — shorten it or drop it`
2073
+ message: `screen ${nn}: subheadline wraps to ~${r.block.subLines} lines (max 1) — shorten it or drop it`
1376
2074
  });
1377
2075
  }
1378
2076
  const p = placements.get(r.index)!;
1379
2077
  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\`` });
2078
+ 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
2079
  }
1382
2080
  });
1383
2081
  if (straddle.size > MAX_STRADDLES) {
@@ -1399,8 +2097,8 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1399
2097
  [s.into, s.cx - fr.W]
1400
2098
  ] as const) {
1401
2099
  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))) {
2100
+ const t = geos[screenIdx].rects;
2101
+ if (textRectList(t).some((rect) => rectsOverlap(rect, box))) {
1404
2102
  warnings.push({ screen: screenIdx, code: 'panorama-seam', message: `screen ${screenIdx + 1}: the straddling device overlaps the text` });
1405
2103
  }
1406
2104
  }
@@ -1414,6 +2112,231 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1414
2112
  return { template: makeTemplate({ name: plan.name, screens, tags: ['generated'] }), report: { warnings, screens: metrics } };
1415
2113
  }
1416
2114
 
2115
+ const DARK_TEXT_FALLBACK = '#111827';
2116
+
2117
+ /**
2118
+ * Where a callout card goes: the crop magnified 1.6–2.2× (less only when the card would not fit,
2119
+ * never below CALLOUT_MAG.floor), centred over the crop's own position on the device, clamped to the
2120
+ * side margins and to the free band between the text block and the far edge. Null (+ warning) when
2121
+ * it can't be shown large enough.
2122
+ */
2123
+ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: TextRects, warnings: ComposeWarning[]) {
2124
+ const req = r.calloutReq!;
2125
+ const { W, H } = r;
2126
+ const shot = r.plan.screenshot;
2127
+ const requested = { x: req.x * shot.width, y: req.y * shot.height, w: req.w * shot.width, h: req.h * shot.height };
2128
+ const scr = p.subject.screen;
2129
+ // Canvas px per screenshot px as the editor fits it into the screen bounds (frameless: the image).
2130
+ const screenScale = (p.scale * scr.width) / shot.width;
2131
+ // Vertical band the card must fit in — computed before padding since it only depends on the text
2132
+ // layout, not the crop, and the padding estimate below needs it too (the same vertical fit cap
2133
+ // applies to both).
2134
+ const texts = textRectList(rects);
2135
+ const gap = CALLOUT_TEXT_GAP * H;
2136
+ const [lo, hi] =
2137
+ r.layout === 'text-bottom'
2138
+ ? [CALLOUT_EDGE * H, Math.min(...texts.map((t) => t.top)) - gap]
2139
+ : [Math.max(...texts.map((t) => t.bottom)) + gap, H * (1 - CALLOUT_EDGE)];
2140
+ // Pad the requested crop on sides that have room inside the screenshot, so its own corners land
2141
+ // inside the rounded clip mask (`makeCalloutLayer`) instead of being cut off there. `pad` is sized
2142
+ // from a PRELIMINARY magnification: the same formula AND the same fit caps (side margins, vertical
2143
+ // room) used below, run on the unpadded crop — since the final mag depends on the padded crop's own
2144
+ // size, this is a deterministic chicken-and-egg break, not the exact final mag. The placement
2145
+ // search below can still shrink the final mag further (retrying smaller sizes when every position
2146
+ // hides too much of the focus band), so `makeCalloutLayer` also hard-caps the mask's own corner
2147
+ // radius as a backstop — the padding here just keeps that cap from binding in the common case.
2148
+ const baseWReq = requested.w * screenScale;
2149
+ const baseHReq = requested.h * screenScale;
2150
+ let prelimMag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseWReq));
2151
+ prelimMag = Math.min(prelimMag, ((1 - 2 * CALLOUT_MARGIN) * W) / baseWReq, (hi - lo) / baseHReq);
2152
+ // Guard against a degenerate (non-positive) estimate — e.g. no vertical room at all — blowing up
2153
+ // `pad`; the real computation below bails out (`callout-skipped`) in that same case anyway, so the
2154
+ // crop's exact shape no longer matters once that happens.
2155
+ prelimMag = Math.max(prelimMag, 1e-6);
2156
+ const pad = (CALLOUT_CORNER_PAD * CALLOUT_RADIUS * W) / (screenScale * prelimMag);
2157
+ const padX0 = Math.max(0, requested.x - pad);
2158
+ const padY0 = Math.max(0, requested.y - pad);
2159
+ const crop = {
2160
+ x: padX0,
2161
+ y: padY0,
2162
+ w: Math.min(shot.width, requested.x + requested.w + pad) - padX0,
2163
+ h: Math.min(shot.height, requested.y + requested.h + pad) - padY0
2164
+ };
2165
+ const centre = subjectPointToCanvas(
2166
+ p.subject,
2167
+ { x: scr.x + ((crop.x + crop.w / 2) / shot.width) * scr.width, y: scr.y + ((crop.y + crop.h / 2) / shot.height) * scr.height },
2168
+ { cx, cy: p.cy, scale: p.scale, angle: p.angle }
2169
+ );
2170
+ const baseW = crop.w * screenScale;
2171
+ const baseH = crop.h * screenScale;
2172
+ let mag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseW));
2173
+ mag = Math.min(mag, ((1 - 2 * CALLOUT_MARGIN) * W) / baseW, (hi - lo) / baseH);
2174
+ if (!(mag >= CALLOUT_MAG.floor)) {
2175
+ warnings.push({
2176
+ screen: r.index,
2177
+ code: 'callout-skipped',
2178
+ message: `screen ${r.index + 1}: the callout crop is too large to magnify (${mag.toFixed(2)}× fits) — mark a smaller \`callout\` region`
2179
+ });
2180
+ return null;
2181
+ }
2182
+ // M2: the focus band (canvas bbox, clipped to the canvas) and the callout's own source slice.
2183
+ const pose = { cx, cy: p.cy, scale: p.scale, angle: p.angle };
2184
+ const bboxOf = (pts: Array<{ x: number; y: number }>): Rect => ({
2185
+ left: Math.min(...pts.map((q) => q.x)),
2186
+ right: Math.max(...pts.map((q) => q.x)),
2187
+ top: Math.min(...pts.map((q) => q.y)),
2188
+ bottom: Math.max(...pts.map((q) => q.y))
2189
+ });
2190
+ const poly = focusPolygon(r, p, cx);
2191
+ const focusRect = poly ? bboxOf(poly) : null;
2192
+ 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) });
2193
+ const toCanvas = (fx: number, fy: number) => subjectPointToCanvas(p.subject, { x: scr.x + fx * scr.width, y: scr.y + fy * scr.height }, pose);
2194
+ 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)]);
2195
+ const cover = (card: Rect) => (focusRect ? focusCoverage(card, focusRect, source) : 0);
2196
+ const m = CALLOUT_MARGIN * W;
2197
+ // Candidates, best first: the largest magnification, then the position closest to the source
2198
+ // (centred on it, then moved up/down, then pushed out to a side edge to break out of the device).
2199
+ 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) {
2200
+ const w = baseW * k;
2201
+ const h = baseH * k;
2202
+ const xs = [Math.min(Math.max(centre.x, m + w / 2), W - m - w / 2), m + w / 2, W - m - w / 2];
2203
+ const ys: number[] = [];
2204
+ const y0 = Math.min(Math.max(centre.y, lo + h / 2), hi - h / 2);
2205
+ for (let t = 0; t <= 24; t++) ys.push(lo + h / 2 + ((hi - lo - h) * t) / 24);
2206
+ let bestRect: Rect | null = null;
2207
+ let bestDist = Infinity;
2208
+ for (const x of xs) {
2209
+ for (const y of [y0, ...ys]) {
2210
+ const rect = rectOf(x, y, w, h);
2211
+ if (cover(rect) > CALLOUT_MAX_FOCUS_COVER + 1e-9) continue;
2212
+ const dist = Math.hypot((x - centre.x) / W, (y - centre.y) / H);
2213
+ if (dist < bestDist - 1e-9) {
2214
+ bestDist = dist;
2215
+ bestRect = rect;
2216
+ }
2217
+ }
2218
+ }
2219
+ if (bestRect) return { rect: bestRect, mag: k, crop, requested, scale: screenScale * k, focusCover: cover(bestRect) };
2220
+ if (k <= CALLOUT_MAG.floor) break;
2221
+ }
2222
+ warnings.push({
2223
+ screen: r.index,
2224
+ code: 'callout-skipped',
2225
+ 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\``
2226
+ });
2227
+ return null;
2228
+ }
2229
+
2230
+ function makeCalloutLayer(r: ResolvedScreen, c: NonNullable<ReturnType<typeof calloutGeometry>>, look: Look): LayerJSON {
2231
+ const id = generateLayerId();
2232
+ // Hard guarantee, independent of how `calloutGeometry`'s padding estimate turned out (it's only a
2233
+ // preliminary-magnification guess — the placement search can shrink the final mag further): the
2234
+ // mask's corner radius can never clip the REQUESTED crop's own corners. On each side that had room
2235
+ // to pad (not clamped flush to the screenshot edge — a clamped side had no padding and was always
2236
+ // going to clip there, mask or no mask), the actual padding applied is the gap between `crop` and
2237
+ // `requested`. The geometric minimum margin to keep a corner inside a rounded corner of local
2238
+ // radius `rx` is `(1 − 1/√2) · rx`, so `rx` is capped at the smallest such padding ÷ (1 − 1/√2) —
2239
+ // converted back to a canvas-unit radius via `c.scale`. If every side is clamped (the crop already
2240
+ // spans the screenshot), there's no padding to guarantee anything from, so the normal radius stands.
2241
+ const shot = r.plan.screenshot;
2242
+ const EPS = 1e-6;
2243
+ const pads: number[] = [];
2244
+ if (c.crop.x > EPS) pads.push(c.requested.x - c.crop.x);
2245
+ if (c.crop.y > EPS) pads.push(c.requested.y - c.crop.y);
2246
+ if (c.crop.x + c.crop.w < shot.width - EPS) pads.push(c.crop.x + c.crop.w - (c.requested.x + c.requested.w));
2247
+ if (c.crop.y + c.crop.h < shot.height - EPS) pads.push(c.crop.y + c.crop.h - (c.requested.y + c.requested.h));
2248
+ const CORNER_INSET = 1 - 1 / Math.SQRT2; // ≈0.293 — geometric minimum margin to keep a corner inside a rounded corner of radius rx
2249
+ const radius = pads.length ? Math.min(CALLOUT_RADIUS * r.W, (Math.min(...pads) / CORNER_INSET) * c.scale) : CALLOUT_RADIUS * r.W;
2250
+ const fabricData: Record<string, unknown> = {
2251
+ type: 'image',
2252
+ src: r.plan.screenshot.url,
2253
+ crossOrigin: 'anonymous',
2254
+ left: (c.rect.left + c.rect.right) / 2,
2255
+ top: (c.rect.top + c.rect.bottom) / 2,
2256
+ width: c.crop.w,
2257
+ height: c.crop.h,
2258
+ cropX: c.crop.x,
2259
+ cropY: c.crop.y,
2260
+ scaleX: c.scale,
2261
+ scaleY: c.scale,
2262
+ originX: 'center',
2263
+ originY: 'center',
2264
+ clipPath: {
2265
+ type: 'Rect',
2266
+ left: 0,
2267
+ top: 0,
2268
+ width: c.crop.w,
2269
+ height: c.crop.h,
2270
+ rx: radius / c.scale,
2271
+ ry: radius / c.scale,
2272
+ originX: 'center',
2273
+ originY: 'center'
2274
+ },
2275
+ imageCornerRadius: radius,
2276
+ layerId: id,
2277
+ layerType: 'image'
2278
+ };
2279
+ if (look.shadows) fabricData.shadow = shadowFor(r.W, c.scale, look, CALLOUT_SHADOW);
2280
+ return { id, name: 'Callout', type: 'image', visible: true, locked: false, fabricData };
2281
+ }
2282
+
2283
+ function makeMascotLayer(m: { rect: Rect; scale: number; flip: boolean; art: ComposeArt }, dx: number, W: number, look: Look, name: string): LayerJSON {
2284
+ const id = generateLayerId();
2285
+ const fabricData: Record<string, unknown> = {
2286
+ type: 'image',
2287
+ src: m.art.url,
2288
+ crossOrigin: 'anonymous',
2289
+ left: (m.rect.left + m.rect.right) / 2 + dx,
2290
+ top: (m.rect.top + m.rect.bottom) / 2,
2291
+ width: m.art.width,
2292
+ height: m.art.height,
2293
+ scaleX: m.scale,
2294
+ scaleY: m.scale,
2295
+ originX: 'center',
2296
+ originY: 'center',
2297
+ layerId: id,
2298
+ layerType: 'image'
2299
+ };
2300
+ if (m.flip) fabricData.flipX = true;
2301
+ if (look.shadows) fabricData.shadow = shadowFor(W, m.scale, look, MASCOT_SHADOW);
2302
+ return { id, name, type: 'image', visible: true, locked: false, fabricData };
2303
+ }
2304
+
2305
+ const MOTIF_STROKE = 0.008; // × W
2306
+
2307
+ /**
2308
+ * Span screen `k`'s part of the motif (already in screen coordinates — see `motifPathForScreen`).
2309
+ * Fabric centres a Path on its bbox, so `left`/`top` = the bbox centre.
2310
+ */
2311
+ function makeMotifLayer(motif: { path: PathCommand[]; bbox: Rect }, k: number, N: number, W: number, darkText: boolean, text: string): LayerJSON {
2312
+ const id = generateLayerId();
2313
+ const stroke = darkText ? 'rgba(255,255,255,0.42)' : rgba(isHexColor(text) ? text : '#FFFFFF', 0.16);
2314
+ return {
2315
+ id,
2316
+ name: `Panorama motif (${k + 1}/${N})`,
2317
+ type: 'shape',
2318
+ visible: true,
2319
+ locked: true,
2320
+ fabricData: {
2321
+ type: 'Path',
2322
+ path: motif.path.map((c) => [...c]),
2323
+ left: (motif.bbox.left + motif.bbox.right) / 2,
2324
+ top: (motif.bbox.top + motif.bbox.bottom) / 2,
2325
+ originX: 'center',
2326
+ originY: 'center',
2327
+ fill: 'rgba(0,0,0,0)',
2328
+ stroke,
2329
+ strokeWidth: MOTIF_STROKE * W,
2330
+ strokeLineJoin: 'round',
2331
+ selectable: false,
2332
+ evented: false,
2333
+ layerId: id,
2334
+ layerType: 'shape',
2335
+ shapeType: 'path'
2336
+ }
2337
+ };
2338
+ }
2339
+
1417
2340
  /**
1418
2341
  * Deterministically assemble a Template from a plan (see `composeSet` for the set-wide layout rules
1419
2342
  * and the lint report). Text layers are marked editable so the user can tweak them in the editor.