@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/variants.ts
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
|
-
import {
|
|
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
|
|
5
|
-
* -
|
|
6
|
-
* - **
|
|
7
|
-
*
|
|
8
|
-
* - **
|
|
9
|
-
*
|
|
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 `
|
|
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
|
|
17
|
-
*
|
|
18
|
-
* -
|
|
19
|
-
*
|
|
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: '
|
|
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
|
-
/**
|
|
38
|
-
export const
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
96
|
-
|
|
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)) :
|
|
104
|
-
const
|
|
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
|
|
198
|
+
...baseStyle(plan),
|
|
199
|
+
bleed: plan.style?.bleed === 'none' ? 'none' : 'deep',
|
|
110
200
|
presentation: 'device',
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// continuous
|
|
115
|
-
|
|
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(
|
|
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 [
|