@appshoteditor/shot-dsl 0.5.2 → 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 +16 -9
- package/package.json +1 -1
- package/src/compose.ts +4 -1
- package/src/layout-system.ts +65 -19
- package/src/variants.ts +21 -13
package/README.md
CHANGED
|
@@ -60,13 +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
|
|
64
|
-
|
|
65
|
-
|
|
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
70
|
- `deep`: grows the subject toward a 25% bleed (still capped at 90% W). When growth alone can't
|
|
68
|
-
reach 25%
|
|
69
|
-
|
|
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%.
|
|
70
75
|
3. **No side tangents.** No subject is wider than 90% of W (`NO_TANGENT.maxWidth`). The part that is
|
|
71
76
|
actually on the canvas keeps ≥ 5% of W from both side edges. The only side bleed is a deliberate
|
|
72
77
|
panorama straddle across a seam.
|
|
@@ -98,7 +103,7 @@ Plan additions (all optional; a 0.3.0 plan composes and validates unchanged):
|
|
|
98
103
|
| Field | Where | Meaning |
|
|
99
104
|
| --- | --- | --- |
|
|
100
105
|
| `style.presentation` | plan | `device` (default) · `frameless` (rounded screenshot + shadow) · `zoom` (magnified crop of `crop`, else the centre of the `focus` band) |
|
|
101
|
-
| `style.tilt`, `style.tiltScreens` | plan | Degrees (clockwise) applied only to the listed screen indices. A tilted subject prefers a bleed under `auto`/`deep
|
|
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. |
|
|
102
107
|
| `style.bleed` | plan | `auto` · `none` · `deep` (see above) |
|
|
103
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). |
|
|
104
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). |
|
|
@@ -234,9 +239,9 @@ Warnings never block composing.
|
|
|
234
239
|
test candidates. Each is named `<name> — A Brand Classic` / `— B Clean Frameless` / `— C Story Panorama`:
|
|
235
240
|
|
|
236
241
|
- **A Brand Classic:** device frames on the `vivid` tonal palette, hero with the mascot (first `art`)
|
|
237
|
-
+ badge,
|
|
242
|
+
+ badge, callouts (the input's explicit `style.callouts`, else `auto`), rhythm every 4 (`text-bottom`).
|
|
238
243
|
- **B Clean Frameless:** `light` tonal palette, full-width frameless screenshots with a `deep` bleed
|
|
239
|
-
(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.
|
|
240
245
|
- **C Story Panorama:** `deep` tonal palette; spans = the input's `panorama.spans`, else triples and
|
|
241
246
|
pairs (`panoramaRuns`); `decoration` = the input's (not `orbs`), else `wave`; a mascot crossing the
|
|
242
247
|
first seam of every span but the hero's; the hero tilted (the input's `style.tilt`, or 8°); bleed
|
|
@@ -252,7 +257,9 @@ test candidates. Each is named `<name> — A Brand Classic` / `— B Clean Frame
|
|
|
252
257
|
**Overridden:**
|
|
253
258
|
- every `layout` becomes `text-top` (the hero and rhythm accents differ on purpose);
|
|
254
259
|
- per-screen `presentation` and `tilt` are dropped;
|
|
255
|
-
- palette mode → `tonal` with the concept's tone; hero, rhythm,
|
|
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.
|
|
256
263
|
|
|
257
264
|
## Layer order
|
|
258
265
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appshoteditor/shot-dsl",
|
|
3
|
-
"version": "0.5.
|
|
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
|
-
/**
|
|
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;
|
package/src/layout-system.ts
CHANGED
|
@@ -36,6 +36,14 @@ export const NO_TANGENT = {
|
|
|
36
36
|
focusSideSafe: 0.02,
|
|
37
37
|
/** A clear-with-margin option that shrinks the subject below this × its natural scale is rejected. */
|
|
38
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,
|
|
39
47
|
/** Below this × the layout's target scale the copy leaves no usable room (composeSet throws). */
|
|
40
48
|
minScale: 0.5
|
|
41
49
|
} as const;
|
|
@@ -186,7 +194,7 @@ export interface VerticalInput {
|
|
|
186
194
|
/** See `focusReach`; null = no focus band marked (unconstrained). */
|
|
187
195
|
focusReach: number | null;
|
|
188
196
|
bleed: BleedPreference;
|
|
189
|
-
/** Tilted subjects:
|
|
197
|
+
/** Tilted subjects: prefers a deeper bleed, but clears rather than leave a gap under the text. */
|
|
190
198
|
preferBleed?: boolean;
|
|
191
199
|
/** Hard upper bound on the scale (e.g. from the horizontal rules); applied to every option. */
|
|
192
200
|
scaleCap?: number;
|
|
@@ -212,18 +220,27 @@ export interface VerticalResult {
|
|
|
212
220
|
* pushes the focus band off-canvas. Options:
|
|
213
221
|
* - CLEAR: keep the near edge, shrink until the far edge clears with the margin.
|
|
214
222
|
* - BLEED: grow (up to its size cap) toward the target overshoot; only when growth alone can't get
|
|
215
|
-
* there (the size cap bites first) does the subject shift toward the far edge
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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.
|
|
221
235
|
* `none` → always clear. `deep` → grow toward `deepBleed` (≥ `minBleed` at the width cap, ≥ `deepBleed`
|
|
222
|
-
* whenever growth alone reaches it), or the most the focus
|
|
236
|
+
* whenever growth alone reaches it, else the minimum-disturbance clear above), or the most the focus
|
|
237
|
+
* allows, else clear.
|
|
223
238
|
* `auto` → keep the natural placement when it already clears by the margin or is a focus-safe
|
|
224
|
-
* decisive bleed; otherwise (the tangent zone) PREFER the bleed option
|
|
225
|
-
* when
|
|
226
|
-
*
|
|
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.
|
|
227
244
|
*/
|
|
228
245
|
export function solveVertical(inp: VerticalInput): VerticalResult {
|
|
229
246
|
const W = inp.canvasWidth;
|
|
@@ -252,10 +269,17 @@ export function solveVertical(inp: VerticalInput): VerticalResult {
|
|
|
252
269
|
/**
|
|
253
270
|
* Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). Growing
|
|
254
271
|
* the subject (up to its size cap — the width cap, or a tighter `scaleCap` when one binds) is
|
|
255
|
-
* always tried first. Only when that growth is capped short of t does the subject
|
|
256
|
-
* far edge
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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.
|
|
259
283
|
*/
|
|
260
284
|
const bleedOption = (t: number): VerticalResult | null => {
|
|
261
285
|
let s = s0;
|
|
@@ -263,7 +287,27 @@ export function solveVertical(inp: VerticalInput): VerticalResult {
|
|
|
263
287
|
if (overshoot(s, a) < t * s * eh - EPS) {
|
|
264
288
|
const need = (H - a) / ((1 - t) * eh);
|
|
265
289
|
s = Math.max(s0, Math.min(need, Math.max(sCap, s0)));
|
|
266
|
-
if (overshoot(s, a) < NO_TANGENT.minBleed * s * eh - EPS)
|
|
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
|
+
}
|
|
267
311
|
}
|
|
268
312
|
const reason =
|
|
269
313
|
overshoot(s, a) >= t * s * eh - EPS
|
|
@@ -300,10 +344,12 @@ export function solveVertical(inp: VerticalInput): VerticalResult {
|
|
|
300
344
|
// auto
|
|
301
345
|
if (naturalClear && !inp.preferBleed) return make(s0, near0, 'natural placement clears the edge');
|
|
302
346
|
if (naturalBleed && focusOk(s0, near0)) return make(s0, near0, 'natural placement bleeds decisively');
|
|
303
|
-
// In the tangent zone (or a tilted subject): resolve by bleeding decisively
|
|
304
|
-
//
|
|
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.
|
|
305
351
|
const bleed = bleedOption(NO_TANGENT.minBleed);
|
|
306
|
-
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;
|
|
307
353
|
if (naturalClear) return make(s0, near0, 'natural placement clears the edge');
|
|
308
354
|
return clearOption() ?? forcedClear();
|
|
309
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
|
|
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
|
|
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
|
|
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`,
|
|
29
|
-
*
|
|
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`, `
|
|
39
|
-
*
|
|
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);
|