@appshoteditor/shot-dsl 0.5.1 → 0.5.3

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 CHANGED
@@ -60,11 +60,18 @@ validateTemplate(template); // { valid: true, errors: [] }
60
60
  2. **No tangents.** A device (or frameless screenshot) either clears the far edge by ≥ 4% of H or
61
61
  bleeds off it by ≥ 12% of its own height. The band in between is the "just touching" look, and
62
62
  `solveVertical` never emits it. `style.bleed` picks how a tangent is resolved:
63
- - `auto` (default): a device that already clears by the margin stays as it is. Otherwise it
64
- bleeds decisively: it grows toward the 90%-of-W cap, then shifts down. It clears with the
65
- margin only when every ≥ 12% bleed would crop its focus band.
63
+ - `auto` (default): a device that already clears by the margin, or already bleeds decisively
64
+ (≥ 12%, without cropping its focus band), stays as it is. Otherwise it grows toward the
65
+ 90%-of-W cap; if that's still short of a 12% bleed, "minimum disturbance" decides between
66
+ clearing and shifting down: it clears at the near edge (no shift) when that clear scale is
67
+ ≥ 88% of the grown/capped scale (`NO_TANGENT.clearOverShift`); otherwise it shifts down only
68
+ as far as exactly a 12% bleed.
66
69
  - `none`: always fully visible.
67
- - `deep`: bleed ≥ 25%.
70
+ - `deep`: grows the subject toward a 25% bleed (still capped at 90% W). When growth alone can't
71
+ reach 25% and a down-shift would be the only way to reach even 12%, the same minimum-disturbance
72
+ rule applies: it clears at the near edge when that costs ≤ 12% of the grown/capped scale;
73
+ otherwise it shifts down only as far as exactly a 12% bleed needs — never deeper, even though
74
+ the target is 25%.
68
75
  3. **No side tangents.** No subject is wider than 90% of W (`NO_TANGENT.maxWidth`). The part that is
69
76
  actually on the canvas keeps ≥ 5% of W from both side edges. The only side bleed is a deliberate
70
77
  panorama straddle across a seam.
@@ -96,7 +103,7 @@ Plan additions (all optional; a 0.3.0 plan composes and validates unchanged):
96
103
  | Field | Where | Meaning |
97
104
  | --- | --- | --- |
98
105
  | `style.presentation` | plan | `device` (default) · `frameless` (rounded screenshot + shadow) · `zoom` (magnified crop of `crop`, else the centre of the `focus` band) |
99
- | `style.tilt`, `style.tiltScreens` | plan | Degrees (clockwise) applied only to the listed screen indices. A tilted subject prefers a bleed under `auto`/`deep`; the tilt is reduced (`tilt-reduced`) if it can't keep the side margins. |
106
+ | `style.tilt`, `style.tiltScreens` | plan | Degrees (clockwise) applied only to the listed screen indices. A tilted subject prefers a deeper bleed under `auto`/`deep`, but clears instead when its scale cap (from keeping the side margins) stops it reaching a decisive bleed without a big down-shift (minimum disturbance, see above); the tilt itself is reduced (`tilt-reduced`) if it can't keep the side margins at all. |
100
107
  | `style.bleed` | plan | `auto` · `none` · `deep` (see above) |
101
108
  | `style.palette` | plan | `{ mode: "tonal" \| "family" \| "sequence", colors: [hex…], tone? }`. Background for screens that omit one; ONE text colour per set (see Art direction). |
102
109
  | `style.panorama` | plan | `{ spans?: [[0,1], …], straddle?: true \| [screen…], decoration?: "orbs" \| "honeycomb" \| "wave" \| "none" }`. Adjacent screens share one continuous background; `straddle` picks the span-start screens whose device crosses the seam (only by ≥ 18% of it). |
@@ -232,9 +239,9 @@ Warnings never block composing.
232
239
  test candidates. Each is named `<name> — A Brand Classic` / `— B Clean Frameless` / `— C Story Panorama`:
233
240
 
234
241
  - **A Brand Classic:** device frames on the `vivid` tonal palette, hero with the mascot (first `art`)
235
- + badge, `callouts: "auto"` (plus explicit ones), rhythm every 4 (`text-bottom`).
242
+ + badge, callouts (the input's explicit `style.callouts`, else `auto`), rhythm every 4 (`text-bottom`).
236
243
  - **B Clean Frameless:** `light` tonal palette, full-width frameless screenshots with a `deep` bleed
237
- (a zoom card only for an explicit `crop` — B1), callouts, hero at 1.3× with the mascot.
244
+ (a zoom card only for an explicit `crop` — B1), callouts (same default as A), hero at 1.3× with the mascot.
238
245
  - **C Story Panorama:** `deep` tonal palette; spans = the input's `panorama.spans`, else triples and
239
246
  pairs (`panoramaRuns`); `decoration` = the input's (not `orbs`), else `wave`; a mascot crossing the
240
247
  first seam of every span but the hero's; the hero tilted (the input's `style.tilt`, or 8°); bleed
@@ -250,7 +257,9 @@ test candidates. Each is named `<name> — A Brand Classic` / `— B Clean Frame
250
257
  **Overridden:**
251
258
  - every `layout` becomes `text-top` (the hero and rhythm accents differ on purpose);
252
259
  - per-screen `presentation` and `tilt` are dropped;
253
- - palette mode → `tonal` with the concept's tone; hero, rhythm, callouts, panorama per concept.
260
+ - palette mode → `tonal` with the concept's tone; hero, rhythm, panorama per concept;
261
+ - `callouts` per concept too, but only C's is fixed (`none`) — A and B keep the input's explicit
262
+ `style.callouts`, defaulting to `auto` only when the input leaves it unset.
254
263
 
255
264
  ## Layer order
256
265
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appshoteditor/shot-dsl",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "App Shot Editor layout DSL + device-frame geometry — framework-free building blocks for composing editable App Store screenshot layouts. Intended for use via a bundler (Vite, esbuild, etc.).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/compose.ts CHANGED
@@ -157,7 +157,10 @@ export interface ComposeMascot {
157
157
  flip?: boolean;
158
158
  }
159
159
 
160
- /** The hero (screen 1 by default): larger type, optional mascot + badge, deeper bleed and/or tilt. */
160
+ /**
161
+ * The hero (screen 1 by default): larger type, optional mascot + badge, and/or tilt. Prefers a deeper
162
+ * bleed, but clears rather than leave a gap under the text.
163
+ */
161
164
  export interface ComposeHero {
162
165
  /** Hero screen index. Default 0. */
163
166
  screen?: number;
@@ -295,6 +298,12 @@ export interface ComposeScreenMetrics {
295
298
  headlineSize: number;
296
299
  /** Headline box top ÷ H (identical across a set by construction). */
297
300
  headlineTop: number;
301
+ /**
302
+ * Bottom of the reserved text block (headline + subheadline + badge row) ÷ H. For `text-bottom`
303
+ * layouts the text block sits near the canvas bottom (not the top), so `top − textBottom` (the
304
+ * subject's top minus this) isn't a meaningful "gap under the text" for that layout.
305
+ */
306
+ textBottom: number;
298
307
  tilt: number;
299
308
  /** Horizontal centre ÷ W (≠ 0.5 for a straddling panorama device). */
300
309
  centerX: number;
@@ -2023,6 +2032,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
2023
2032
  tangent: inTangentZone(overshoot, p.boxHeight, H),
2024
2033
  headlineSize: typo.headlineSize / W,
2025
2034
  headlineTop: g.headlineTop / H,
2035
+ textBottom: (g.areaTop + typo.textArea) / H,
2026
2036
  tilt: p.angle,
2027
2037
  centerX: cx / W,
2028
2038
  role: r.role,
@@ -17,7 +17,11 @@ export const NO_TANGENT = {
17
17
  clearGap: 0.04,
18
18
  /** A subject that bleeds must lose at least this × its own (rotated) height. */
19
19
  minBleed: 0.12,
20
- /** `bleed: "deep"` target, × the subject's height. */
20
+ /**
21
+ * `bleed: "deep"` target, × the subject's height. Reached by growing the subject (up to
22
+ * `maxWidth`·W) — never by shifting it down past a decisive (`minBleed`) overshoot, since a
23
+ * further down-shift would just be empty space between the text and the subject.
24
+ */
21
25
  deepBleed: 0.25,
22
26
  /** The focus band must end at least this × H inside the canvas edge. */
23
27
  focusSafe: 0.02,
@@ -32,6 +36,14 @@ export const NO_TANGENT = {
32
36
  focusSideSafe: 0.02,
33
37
  /** A clear-with-margin option that shrinks the subject below this × its natural scale is rejected. */
34
38
  minClearScale: 0.6,
39
+ /**
40
+ * "Minimum disturbance": when growing toward a decisive bleed is capped short and the only way to
41
+ * reach `minBleed` is to shift the subject down (away from the text), shrinking it to clear the
42
+ * edge instead is preferred whenever that clear scale is still at least this × the (grown/capped)
43
+ * bleed scale — shrinking by ≤ 12% to clear beats pushing the subject down and leaving a gap under
44
+ * the text.
45
+ */
46
+ clearOverShift: 0.88,
35
47
  /** Below this × the layout's target scale the copy leaves no usable room (composeSet throws). */
36
48
  minScale: 0.5
37
49
  } as const;
@@ -182,7 +194,7 @@ export interface VerticalInput {
182
194
  /** See `focusReach`; null = no focus band marked (unconstrained). */
183
195
  focusReach: number | null;
184
196
  bleed: BleedPreference;
185
- /** Tilted subjects: prefer a decisive bleed whenever one is possible (tilt is paired with a bleed). */
197
+ /** Tilted subjects: prefers a deeper bleed, but clears rather than leave a gap under the text. */
186
198
  preferBleed?: boolean;
187
199
  /** Hard upper bound on the scale (e.g. from the horizontal rules); applied to every option. */
188
200
  scaleCap?: number;
@@ -207,14 +219,28 @@ export interface VerticalResult {
207
219
  * ≥ `clearGap`·H or bleeds by ≥ `minBleed` of the subject's height — never in between — and never
208
220
  * pushes the focus band off-canvas. Options:
209
221
  * - CLEAR: keep the near edge, shrink until the far edge clears with the margin.
210
- * - BLEED: grow (up to `maxWidth`·W), then shift toward the far edge, until the overshoot reaches
211
- * the target; if that hides the focus band, the largest focus-safe bleed ≥ `minBleed` is used,
212
- * else the bleed option is unavailable.
213
- * `none` → always clear. `deep` → bleed ≥ `deepBleed` (or the most the focus allows), else clear.
222
+ * - BLEED: grow (up to its size cap) toward the target overshoot; only when growth alone can't get
223
+ * there (the size cap bites first) does the subject need to shift toward the far edge to reach a
224
+ * decisive (`minBleed`) overshoot. When that shift would itself stay focus-safe, "minimum
225
+ * disturbance" is tried first: if shrinking the (grown/capped) subject back down clears the edge
226
+ * with the margin and loses at most `1 − clearOverShift` of that scale, that clear placement
227
+ * (sitting at near0, undisturbed) is used INSTEAD of shifting it down — see
228
+ * `NO_TANGENT.clearOverShift`. Only when clearing would shrink it more than that (or the shift
229
+ * wouldn't be focus-safe in the first place, so there's no real shift to prefer clearing over) does
230
+ * it fall back to shifting down, and even then never further than `minBleed` needs — never all the
231
+ * way to a deeper target, which would otherwise leave empty space between the text and the subject.
232
+ * If the (shifted) result hides the focus band, the placement instead targets the smallest decisive
233
+ * bleed (`minBleed`) that stays focus-safe — or near0 itself, when near0 already sits further down
234
+ * than that — else the bleed option is unavailable.
235
+ * `none` → always clear. `deep` → grow toward `deepBleed` (≥ `minBleed` at the width cap, ≥ `deepBleed`
236
+ * whenever growth alone reaches it, else the minimum-disturbance clear above), or the most the focus
237
+ * allows, else clear.
214
238
  * `auto` → keep the natural placement when it already clears by the margin or is a focus-safe
215
- * decisive bleed; otherwise (the tangent zone) PREFER the bleed option, and fall back to clear only
216
- * when every ≥ `minBleed` bleed would crop the focus band. `preferBleed` (tilt) also turns a natural
217
- * clear into a bleed.
239
+ * decisive bleed; otherwise (the tangent zone) PREFER the bleed option — or its minimum-disturbance
240
+ * clear, when a shallow shrink beats the shift a decisive bleed would need — and fall back to clear
241
+ * when no ≥ `minBleed` bleed keeps the focus band visible.
242
+ * `preferBleed` (tilt) also turns a natural clear into a bleed — or its minimum-disturbance clear, when
243
+ * the tilt's scale cap stops it from reaching a decisive bleed without a shift.
218
244
  */
219
245
  export function solveVertical(inp: VerticalInput): VerticalResult {
220
246
  const W = inp.canvasWidth;
@@ -240,24 +266,63 @@ export function solveVertical(inp: VerticalInput): VerticalResult {
240
266
  return make(s, near0, s < s0 - EPS ? 'shrunk to clear the edge with a margin' : 'clears the edge');
241
267
  };
242
268
 
243
- /** Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). */
269
+ /**
270
+ * Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). Growing
271
+ * the subject (up to its size cap — the width cap, or a tighter `scaleCap` when one binds) is
272
+ * always tried first. Only when that growth is capped short of t does the subject need to shift
273
+ * toward the far edge to reach a decisive (`minBleed`) overshoot — and even then, only when that
274
+ * shift would actually stay focus-safe, "minimum disturbance" is tried first: shrinking the grown
275
+ * subject back down to clear the edge, if that only costs ≤ `1 − clearOverShift` of its scale,
276
+ * beats shifting it away from the text. (When the shift itself wouldn't be focus-safe, there's no
277
+ * real shift to prefer clearing over, so this is skipped — the focus-constrained fallback below
278
+ * decides instead, exactly as it did before minimum disturbance existed.) Otherwise — clearing
279
+ * would shrink it more than that, or no shift is possible to compare against — it shifts down, and
280
+ * even then never further than `minBleed` needs, never all the way to t: a deeper target like
281
+ * `deepBleed` is something to grow toward, not something worth shifting the subject away from the
282
+ * text to force.
283
+ */
244
284
  const bleedOption = (t: number): VerticalResult | null => {
245
285
  let s = s0;
246
286
  let a = near0;
247
287
  if (overshoot(s, a) < t * s * eh - EPS) {
248
288
  const need = (H - a) / ((1 - t) * eh);
249
289
  s = Math.max(s0, Math.min(need, Math.max(sCap, s0)));
250
- if (overshoot(s, a) < t * s * eh - EPS) a = H - s * eh * (1 - t);
290
+ if (overshoot(s, a) < NO_TANGENT.minBleed * s * eh - EPS) {
291
+ const shifted = H - s * eh * (1 - NO_TANGENT.minBleed);
292
+ // Minimum disturbance only competes with a shift that would actually work: if shifting
293
+ // down to `shifted` wouldn't even keep the focus band visible, there's no real shift
294
+ // alternative to prefer clearing over — let the focus-constrained path below (and its
295
+ // own reasons) decide instead, same as before minimum disturbance existed.
296
+ if (focusOk(s, shifted)) {
297
+ // Prefer shrinking to clear (undisturbed, at near0) over shifting the subject down,
298
+ // as long as that shrink is shallow (≤ 1 − clearOverShift of the bleed's own
299
+ // grown/capped scale) — a small shrink beats a gap-forcing down-shift.
300
+ const clear = clearOption();
301
+ if (clear && clear.scale >= NO_TANGENT.clearOverShift * s - EPS) {
302
+ const reason =
303
+ clear.scale < s0 - EPS
304
+ ? `shrunk ${Math.round((1 - clear.scale / s0) * 100)}% to clear instead of shifting down for a bleed`
305
+ : 'clears the edge (preferred over shifting down for a bleed)';
306
+ return { ...clear, reason };
307
+ }
308
+ }
309
+ a = shifted;
310
+ }
251
311
  }
252
- if (focusOk(s, a)) return make(s, a, `bleeds ${Math.round(t * 100)}%+`);
312
+ const reason =
313
+ overshoot(s, a) >= t * s * eh - EPS
314
+ ? `bleeds ${Math.round(t * 100)}%+`
315
+ : `grows to its size cap (no gap forced to reach ${Math.round(t * 100)}%+)`;
316
+ if (focusOk(s, a)) return make(s, a, reason);
253
317
  // Focus-constrained: keep the scale (≤ what the focus allows at near0) and bleed only as deep
254
- // as the focus band allows — accepted if that is still a decisive (≥ minBleed) bleed.
318
+ // as the focus band allows — accepted if that is still a decisive (≥ minBleed) bleed. The shift
319
+ // itself only ever targets minBleed (never t), for the same no-gap reason as above.
255
320
  if (inp.focusReach == null) return null;
256
321
  const fr = inp.focusReach;
257
322
  const sF = Math.min(s, Math.max(sCap, s0), (focusLimit - near0) / fr);
258
323
  const denom = eh * (1 - NO_TANGENT.minBleed) - fr;
259
324
  if (!(sF > 0) || denom <= 0 || sF < (H - focusLimit) / denom - EPS) return null;
260
- const aF = Math.max(near0, Math.min(focusLimit - sF * fr, H - sF * eh * (1 - t)));
325
+ const aF = Math.max(near0, Math.min(focusLimit - sF * fr, H - sF * eh * (1 - NO_TANGENT.minBleed)));
261
326
  if (overshoot(sF, aF) < NO_TANGENT.minBleed * sF * eh - EPS || !focusOk(sF, aF)) return null;
262
327
  return make(sF, aF, 'bleed reduced to keep the focus band visible');
263
328
  };
@@ -279,10 +344,12 @@ export function solveVertical(inp: VerticalInput): VerticalResult {
279
344
  // auto
280
345
  if (naturalClear && !inp.preferBleed) return make(s0, near0, 'natural placement clears the edge');
281
346
  if (naturalBleed && focusOk(s0, near0)) return make(s0, near0, 'natural placement bleeds decisively');
282
- // In the tangent zone (or a tilted subject): resolve by bleeding decisively. Clear-with-margin
283
- // only when no ≥ minBleed bleed keeps the focus band visible.
347
+ // In the tangent zone (or a tilted subject): resolve by bleeding decisively — or its
348
+ // minimum-disturbance clear, when the size cap stops a decisive bleed without a shift.
349
+ // Clear-with-margin (the naturalClear fallback below) only when no ≥ minBleed bleed keeps the
350
+ // focus band visible.
284
351
  const bleed = bleedOption(NO_TANGENT.minBleed);
285
- if (bleed) return inp.preferBleed ? { ...bleed, reason: `${bleed.reason} (tilt pairs with a bleed)` } : bleed;
352
+ if (bleed) return inp.preferBleed && bleed.mode === 'bleed' ? { ...bleed, reason: `${bleed.reason} (tilt pairs with a bleed)` } : bleed;
286
353
  if (naturalClear) return make(s0, near0, 'natural placement clears the edge');
287
354
  return clearOption() ?? forcedClear();
288
355
  }
package/src/variants.ts CHANGED
@@ -14,19 +14,22 @@ import type { Motif } from './decor';
14
14
  * Three DISTINCT art directions from one plan — Product Page Optimization test candidates, not tweaks
15
15
  * (shot-dsl 0.5.0):
16
16
  * - **A Brand Classic**: device frames on the vivid tonal brand palette; hero with the mascot + badge;
17
- * callouts on the selling screens (explicit, else from a tight `focus`); an accent rhythm (every 4th screen text-bottom); shadows.
17
+ * callouts on the selling screens by default (explicit, else from a tight `focus`; the input can
18
+ * turn this off with `style.callouts: 'none'`); an accent rhythm (every 4th screen text-bottom); shadows.
18
19
  * - **B Clean Frameless**: pale tonal backgrounds with dark text; big full-width frameless
19
20
  * screenshots bleeding deep off the bottom (a zoom card only where the plan marks an explicit
20
- * `crop`); magnified callouts (explicit, else derived from a tight `focus`); hero with large type
21
- * + the mascot.
21
+ * `crop`); magnified callouts by default (explicit, else derived from a tight `focus`; same
22
+ * `style.callouts: 'none'` opt-out as A); hero with large type + the mascot.
22
23
  * - **C Story Panorama**: the deep tonal palette as ONE continuous scene across triples/pairs of
23
24
  * screens with a flowing motif (`style.panorama.decoration`, default `wave`), the mascot
24
- * travelling across the seams, a tilted hero and deeper bleeds.
25
+ * travelling across the seams, a tilted hero that prefers a deeper bleed (but clears rather than
26
+ * leave a gap under the text).
25
27
  *
26
28
  * KEPT from the input (every concept): name (+ suffix), canvas size, copy (headline / subheadline /
27
29
  * badge), colours (headlineColor / subheadlineColor), `background`, `deviceId`, `screenshot`,
28
- * `focus`, `crop`, `callout`, per-screen `mascot`, `art`, and the style's `font` and `bleed`
29
- * preference (default `auto`; B and C use `deep` unless the input says `none`).
30
+ * `focus`, `crop`, `callout`, per-screen `mascot`, `art`, the style's `font`, its `bleed` preference
31
+ * (default `auto`; B and C use `deep` unless the input says `none`), and its `callouts` preference
32
+ * (default `auto` for A and B if the input doesn't set one explicitly; C is always `none`).
30
33
  *
31
34
  * BRAND COLOURS: `style.palette.colors` (the first is the base, a tonal palette's second is the
32
35
  * accent); without a palette, the first screen's background colour. Concepts then use
@@ -35,8 +38,10 @@ import type { Motif } from './decor';
35
38
  * OVERRIDDEN (the concept decides these):
36
39
  * - every screen's `layout` → `text-top` (one shared system; the hero and rhythm accents differ);
37
40
  * - per-screen `presentation` / `tilt` are dropped;
38
- * - `hero`, `rhythm`, `callouts`, `tilt`/`tiltScreens` and `panorama` are set per concept (C keeps the
39
- * input's `panorama.spans` / `decoration` when given).
41
+ * - `hero`, `rhythm`, `tilt`/`tiltScreens` and `panorama` are set per concept (C keeps the input's
42
+ * `panorama.spans` / `decoration` when given);
43
+ * - `callouts` is set per concept too, but only C's is fixed (`none`) regardless of the input — A and
44
+ * B fall back to `auto` only when the input leaves `callouts` unset (see `baseStyle`).
40
45
  */
41
46
  export type VariantKey = 'A' | 'B' | 'C';
42
47
 
@@ -80,9 +85,14 @@ function tonal(plan: ComposePlan, tone: 'light' | 'vivid' | 'deep'): ComposePale
80
85
  return { mode: 'tonal', colors: brand.accent ? [brand.base, brand.accent] : [brand.base], tone };
81
86
  }
82
87
 
88
+ /**
89
+ * Style knobs every concept inherits from the input, defaulted the same way for all three: `bleed`
90
+ * (default `auto`), `font` (kept only if set), and `callouts` (default `auto`) — concepts that want a
91
+ * different `callouts` (currently only C, always `none`) set it explicitly afterwards, which wins.
92
+ */
83
93
  function baseStyle(plan: ComposePlan): ComposeStyle {
84
94
  const style = plan.style;
85
- const out: ComposeStyle = { bleed: style?.bleed ?? 'auto' };
95
+ const out: ComposeStyle = { bleed: style?.bleed ?? 'auto', callouts: style?.callouts ?? 'auto' };
86
96
  if (style?.font) out.font = style.font;
87
97
  return out;
88
98
  }
@@ -160,8 +170,7 @@ export function makeVariants(plan: ComposePlan): ComposeVariant[] {
160
170
  presentation: 'device',
161
171
  palette: tonal(plan, 'vivid'),
162
172
  hero: hero(plan, { badge }),
163
- rhythm: { every: VARIANT_RHYTHM_EVERY, treatment: 'text-bottom' },
164
- callouts: 'auto'
173
+ rhythm: { every: VARIANT_RHYTHM_EVERY, treatment: 'text-bottom' }
165
174
  },
166
175
  screens: plan.screens.map(baseScreen)
167
176
  });
@@ -175,8 +184,7 @@ export function makeVariants(plan: ComposePlan): ComposeVariant[] {
175
184
  bleed: plan.style?.bleed === 'none' ? 'none' : 'deep',
176
185
  presentation: 'frameless',
177
186
  palette: tonal(plan, 'light'),
178
- hero: hero(plan, { badge, scale: 1.3 }),
179
- callouts: 'auto'
187
+ hero: hero(plan, { badge, scale: 1.3 })
180
188
  },
181
189
  screens: plan.screens.map((screen) => {
182
190
  const s = baseScreen(screen);