@appshoteditor/shot-dsl 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/variants.ts CHANGED
@@ -1,26 +1,42 @@
1
- import { canvasDimsForDevice, type ComposePlan, type ComposeScreenPlan, type ComposeStyle } from './compose';
1
+ import {
2
+ canvasDimsForDevice,
3
+ type ComposeHero,
4
+ type ComposeMascot,
5
+ type ComposePalette,
6
+ type ComposePlan,
7
+ type ComposeScreenPlan,
8
+ type ComposeStyle
9
+ } from './compose';
10
+ import { isHexColor } from './color';
11
+ import type { Motif } from './decor';
2
12
 
3
13
  /**
4
- * Three DISTINCT concepts from one plan — Product Page Optimization test candidates, not tweaks:
5
- * - **A Framed**: device mockups, straight, one shared layout.
6
- * - **B Frameless**: bare rounded screenshots; screens with a `crop` (or a tight `focus` band) become
7
- * magnified zoom cards of that UI.
8
- * - **C Panorama**: adjacent pairs share one continuous background + seam orbs; only the tilted hero
9
- * (screen 1) straddles its seam — one crossing per set, rotation stays an accent.
14
+ * Three DISTINCT art directions from one plan — Product Page Optimization test candidates, not tweaks
15
+ * (shot-dsl 0.5.0):
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.
18
+ * - **B Clean Frameless**: pale tonal backgrounds with dark text; big full-width frameless
19
+ * 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.
22
+ * - **C Story Panorama**: the deep tonal palette as ONE continuous scene across triples/pairs of
23
+ * screens with a flowing motif (`style.panorama.decoration`, default `wave`), the mascot
24
+ * travelling across the seams, a tilted hero and deeper bleeds.
10
25
  *
11
26
  * KEPT from the input (every concept): name (+ suffix), canvas size, copy (headline / subheadline /
12
27
  * badge), colours (headlineColor / subheadlineColor), `background`, `deviceId`, `screenshot`,
13
- * `focus`, `crop`, and the style's `palette`, `font` and `bleed` preference (default `auto`).
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
+ *
31
+ * BRAND COLOURS: `style.palette.colors` (the first is the base, a tonal palette's second is the
32
+ * accent); without a palette, the first screen's background colour. Concepts then use
33
+ * `mode: "tonal"` with their own tone. Without any brand colour, the input palette/backgrounds stay.
14
34
  *
15
35
  * OVERRIDDEN (the concept decides these):
16
- * - every screen's `layout` → `text-top`: screens only share one device scale + baseline when they
17
- * share a layout, and that shared system is what makes a set look designed;
18
- * - per-screen `presentation` / `tilt` are dropped; `style.presentation` is `device` (A, C) or
19
- * `frameless` (B, with `zoom` on screens that have a `crop` or a focus band ≤ 45% tall);
20
- * - A and B are straight and without panorama (`tilt`, `tiltScreens`, `panorama` removed);
21
- * - C tilts screen 1 only (`tiltScreens: [0]`, the input's non-zero `style.tilt` or 8°) and uses the
22
- * input's `panorama.spans` if it has them (else adjacent pairs of the same canvas size); its
23
- * `straddle` is the input's if set, else the hero only; `decoration` defaults to `orbs`.
36
+ * - every screen's `layout` → `text-top` (one shared system; the hero and rhythm accents differ);
37
+ * - 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).
24
40
  */
25
41
  export type VariantKey = 'A' | 'B' | 'C';
26
42
 
@@ -30,12 +46,12 @@ export interface ComposeVariant {
30
46
  plan: ComposePlan;
31
47
  }
32
48
 
33
- export const VARIANT_LABELS: Record<VariantKey, string> = { A: 'Framed', B: 'Frameless', C: 'Panorama' };
49
+ export const VARIANT_LABELS: Record<VariantKey, string> = { A: 'Brand Classic', B: 'Clean Frameless', C: 'Story Panorama' };
34
50
 
35
51
  /** Tilt (degrees) the panorama concept gives its hero screen. */
36
52
  export const VARIANT_HERO_TILT = 8;
37
- /** A focus band at most this tall (fraction of the screenshot) is worth a zoom card in concept B. */
38
- export const VARIANT_ZOOM_MAX_FOCUS = 0.45;
53
+ /** Rhythm period of concept A (every 4th screen is an accent). */
54
+ export const VARIANT_RHYTHM_EVERY = 4;
39
55
 
40
56
  const suffix = (name: string, key: VariantKey) => `${name} — ${key} ${VARIANT_LABELS[key]}`;
41
57
 
@@ -48,15 +64,65 @@ function baseScreen(screen: ComposeScreenPlan): ComposeScreenPlan {
48
64
  return { ...copy, layout: 'text-top' };
49
65
  }
50
66
 
51
- function baseStyle(style: ComposeStyle | undefined): ComposeStyle {
67
+ /** The plan's brand colours: [base, accent?] (see module doc), or null. */
68
+ export function brandColors(plan: ComposePlan): { base: string; accent?: string } | null {
69
+ const pal = plan.style?.palette;
70
+ const colors = (pal?.colors ?? []).filter(isHexColor);
71
+ if (colors.length) return { base: colors[0], accent: pal?.mode === 'tonal' ? colors[1] : undefined };
72
+ const bg = plan.screens[0]?.background;
73
+ const first = bg?.type === 'gradient' ? (bg.gradient?.colorStops?.[0]?.color ?? bg.gradient?.colors?.[0]) : bg?.color;
74
+ return isHexColor(first) ? { base: first } : null;
75
+ }
76
+
77
+ function tonal(plan: ComposePlan, tone: 'light' | 'vivid' | 'deep'): ComposePalette | undefined {
78
+ const brand = brandColors(plan);
79
+ if (!brand) return plan.style?.palette ? JSON.parse(JSON.stringify(plan.style.palette)) : undefined;
80
+ return { mode: 'tonal', colors: brand.accent ? [brand.base, brand.accent] : [brand.base], tone };
81
+ }
82
+
83
+ function baseStyle(plan: ComposePlan): ComposeStyle {
84
+ const style = plan.style;
52
85
  const out: ComposeStyle = { bleed: style?.bleed ?? 'auto' };
53
- if (style?.palette) out.palette = JSON.parse(JSON.stringify(style.palette));
54
86
  if (style?.font) out.font = style.font;
55
87
  return out;
56
88
  }
57
89
 
90
+ /** The hero mascot: the input hero's, else the first art, anchored by the headline. */
91
+ function heroMascot(plan: ComposePlan, size?: number): ComposeMascot | undefined {
92
+ const given = plan.style?.hero && typeof plan.style.hero === 'object' ? plan.style.hero.mascot : undefined;
93
+ if (given) return { ...given };
94
+ const art = plan.art?.[0];
95
+ return art ? { art: art.id, anchor: 'headline', ...(size ? { size } : {}) } : undefined;
96
+ }
97
+
98
+ /** The input's hero screen index (-1 when the input turns the hero off). */
99
+ function heroIndexOf(plan: ComposePlan): number {
100
+ const h = plan.style?.hero;
101
+ if (h === false) return -1;
102
+ const i = h && typeof h === 'object' && Number.isFinite(h.screen) ? Math.floor(h.screen!) : 0;
103
+ return Math.min(Math.max(0, i), Math.max(0, plan.screens.length - 1));
104
+ }
105
+
106
+ /** The concept's hero: the input's (incl. `screen`) + the concept's overrides; `false` stays off. */
107
+ function hero(plan: ComposePlan, extra: ComposeHero, mascotSize?: number): ComposeHero | false {
108
+ if (plan.style?.hero === false) return false;
109
+ const input = plan.style?.hero && typeof plan.style.hero === 'object' ? plan.style.hero : {};
110
+ const out: ComposeHero = { ...JSON.parse(JSON.stringify(input)), ...extra };
111
+ const mascot = heroMascot(plan, mascotSize);
112
+ if (mascot) out.mascot = mascot;
113
+ return out;
114
+ }
115
+
58
116
  /** Adjacent pairs [0,1], [2,3], … of screens that share a canvas size (a lone last screen stays single). */
59
117
  export function panoramaPairs(plan: ComposePlan): number[][] {
118
+ return panoramaRuns(plan, [2]);
119
+ }
120
+
121
+ /**
122
+ * Runs of adjacent same-canvas screens with lengths cycling through `sizes` (e.g. [3, 2] → triples
123
+ * and pairs), never leaving a lone screen when a shorter run fits.
124
+ */
125
+ export function panoramaRuns(plan: ComposePlan, sizes: number[] = [3, 2]): number[][] {
60
126
  const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
61
127
  const key = (s: ComposeScreenPlan) => {
62
128
  if (explicit) return 'explicit';
@@ -64,57 +130,88 @@ export function panoramaPairs(plan: ComposePlan): number[][] {
64
130
  return `${d.width}x${d.height}`;
65
131
  };
66
132
  const spans: number[][] = [];
67
- for (let i = 0; i + 1 < plan.screens.length; ) {
68
- if (key(plan.screens[i]) === key(plan.screens[i + 1])) {
69
- spans.push([i, i + 1]);
70
- i += 2;
71
- } else {
72
- i += 1;
73
- }
133
+ let cycle = 0;
134
+ for (let i = 0; i < plan.screens.length; ) {
135
+ let len = 1;
136
+ while (i + len < plan.screens.length && key(plan.screens[i + len]) === key(plan.screens[i])) len++;
137
+ // `len` = same-canvas run from i; take the cycle's size, or the largest that fits.
138
+ let take = Math.min(sizes[cycle % sizes.length], len);
139
+ // Don't strand a single screen after this span when a smaller span would pair it up.
140
+ if (len - take === 1 && take > 2) take = 2;
141
+ if (take >= 2) {
142
+ spans.push(Array.from({ length: take }, (_, k) => i + k));
143
+ cycle++;
144
+ i += take;
145
+ } else i += 1;
74
146
  }
75
147
  return spans;
76
148
  }
77
149
 
78
150
  export function makeVariants(plan: ComposePlan): ComposeVariant[] {
79
- const common = { canvasWidth: plan.canvasWidth, canvasHeight: plan.canvasHeight };
151
+ const common = { canvasWidth: plan.canvasWidth, canvasHeight: plan.canvasHeight, art: plan.art };
80
152
  const strip = <T extends object>(o: T): T => JSON.parse(JSON.stringify(o)); // drops undefined keys
153
+ const badge = (plan.style?.hero && typeof plan.style.hero === 'object' ? plan.style.hero.badge : undefined) ?? undefined;
81
154
 
82
155
  const a: ComposePlan = strip({
83
156
  ...common,
84
157
  name: suffix(plan.name, 'A'),
85
- style: { ...baseStyle(plan.style), presentation: 'device' },
158
+ style: {
159
+ ...baseStyle(plan),
160
+ presentation: 'device',
161
+ palette: tonal(plan, 'vivid'),
162
+ hero: hero(plan, { badge }),
163
+ rhythm: { every: VARIANT_RHYTHM_EVERY, treatment: 'text-bottom' },
164
+ callouts: 'auto'
165
+ },
86
166
  screens: plan.screens.map(baseScreen)
87
167
  });
88
168
 
89
169
  const b: ComposePlan = strip({
90
170
  ...common,
91
171
  name: suffix(plan.name, 'B'),
92
- style: { ...baseStyle(plan.style), presentation: 'frameless' },
172
+ style: {
173
+ ...baseStyle(plan),
174
+ // Big screenshots bleeding decisively off the bottom: the editorial "clean" look.
175
+ bleed: plan.style?.bleed === 'none' ? 'none' : 'deep',
176
+ presentation: 'frameless',
177
+ palette: tonal(plan, 'light'),
178
+ hero: hero(plan, { badge, scale: 1.3 }),
179
+ callouts: 'auto'
180
+ },
93
181
  screens: plan.screens.map((screen) => {
94
182
  const s = baseScreen(screen);
95
- const f = screen.focus;
96
- const tight = !!f && Math.abs(f.bottom - f.top) <= VARIANT_ZOOM_MAX_FOCUS;
97
- if (screen.crop || tight) s.presentation = 'zoom';
183
+ // B1: only an explicit `crop` becomes a zoom card; everything else shows the full screen.
184
+ if (screen.crop) s.presentation = 'zoom';
98
185
  return s;
99
186
  })
100
187
  });
101
188
 
102
189
  const pano = plan.style?.panorama;
103
- const spans = pano?.spans?.length ? JSON.parse(JSON.stringify(pano.spans)) : panoramaPairs(plan);
104
- const heroStraddle = spans.some((span: number[]) => span[0] === 0) ? [0] : [];
190
+ const spans: number[][] = pano?.spans?.length ? JSON.parse(JSON.stringify(pano.spans)) : panoramaRuns(plan);
191
+ const decoration: Motif = pano?.decoration && pano.decoration !== 'orbs' ? pano.decoration : 'wave';
192
+ const art = plan.art?.[0];
193
+ const heroIndex = heroIndexOf(plan);
105
194
  const c: ComposePlan = strip({
106
195
  ...common,
107
196
  name: suffix(plan.name, 'C'),
108
197
  style: {
109
- ...baseStyle(plan.style),
198
+ ...baseStyle(plan),
199
+ bleed: plan.style?.bleed === 'none' ? 'none' : 'deep',
110
200
  presentation: 'device',
111
- tilt: plan.style?.tilt ? plan.style.tilt : VARIANT_HERO_TILT,
112
- tiltScreens: [0],
113
- // Only the hero straddles by default (≤ 1 seam crossing per set); other spans keep the
114
- // continuous background + orbs with centred, straight devices.
115
- panorama: { spans, straddle: pano?.straddle ?? heroStraddle, decoration: pano?.decoration ?? 'orbs' }
201
+ palette: tonal(plan, 'deep'),
202
+ hero: hero(plan, { badge, tilt: plan.style?.tilt ? plan.style.tilt : VARIANT_HERO_TILT }, 0.26),
203
+ callouts: 'none',
204
+ // The continuous scene carries the story; a straddle only when the input asks for one
205
+ // (a straddle needs ≥ 18% of the device on the next screen, which a focus band rarely allows).
206
+ panorama: { spans, ...(pano?.straddle !== undefined ? { straddle: pano.straddle } : {}), decoration }
116
207
  },
117
- screens: plan.screens.map(baseScreen)
208
+ screens: plan.screens.map((screen, i) => {
209
+ const s = baseScreen(screen);
210
+ // The mascot travels: it crosses the first seam of every span except the hero's.
211
+ const span = spans.find((sp) => sp[0] === i);
212
+ if (art && span && !span.includes(heroIndex) && !s.mascot) s.mascot = { art: art.id, anchor: 'seam', size: 0.2 };
213
+ return s;
214
+ })
118
215
  });
119
216
 
120
217
  return [