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