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