@appshoteditor/shot-dsl 0.1.1 → 0.2.0
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 +6 -0
- package/package.json +1 -1
- package/src/builders.ts +3 -1
- package/src/compose.ts +187 -21
- package/src/device-frames.ts +15 -0
- package/src/types.ts +8 -0
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@ composer — so a layout composed by the skill renders identically in the editor
|
|
|
15
15
|
`makeScreen`, `makeTemplate`.
|
|
16
16
|
- **Device geometry** — `deviceFrames`, `getDeviceFrame`, `makeDeviceFrameLayers`, `calculateDeviceScale`.
|
|
17
17
|
- **Composer** — `composeTemplate(plan)`: a benefit/screenshot plan → a validated, device-framed `Template`.
|
|
18
|
+
Per screen: `headline`, optional `subheadline` / `headlineColor` / `subheadlineColor`, and `layout`
|
|
19
|
+
(`text-top` default, `text-bottom`, `device-bleed` — see `COMPOSE_LAYOUTS`). All geometry (device
|
|
20
|
+
scale/position, font sizes, padding) is derived from the canvas size, so a plan composes to the
|
|
21
|
+
same proportions at 280×608 editor units or 1320×2868 native pixels.
|
|
18
22
|
|
|
19
23
|
```ts
|
|
20
24
|
import { composeTemplate, validateTemplate } from '@appshoteditor/shot-dsl';
|
|
@@ -24,6 +28,8 @@ const template = composeTemplate({
|
|
|
24
28
|
screens: [
|
|
25
29
|
{
|
|
26
30
|
headline: 'Track every workout',
|
|
31
|
+
subheadline: 'Sets, reps and rest — logged for you',
|
|
32
|
+
layout: 'text-top',
|
|
27
33
|
background: { type: 'gradient', gradient: { type: 'linear', colorStops: [/* … */] } },
|
|
28
34
|
deviceId: 'iphone_16_pro',
|
|
29
35
|
screenshot: { url: '…', width: 1179, height: 2556 }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appshoteditor/shot-dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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/builders.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BackgroundJSON, ColorStop, LayerJSON, ScreenLayersJSON, Template, TemplateRole } from './types';
|
|
1
|
+
import type { BackgroundJSON, ColorStop, DeviceClass, LayerJSON, ScreenLayersJSON, Template, TemplateRole } from './types';
|
|
2
2
|
import { CURRENT_SCHEMA_VERSION } from './types';
|
|
3
3
|
import { generateLayerId } from './validate';
|
|
4
4
|
|
|
@@ -176,11 +176,13 @@ export function makeScreen(opts: {
|
|
|
176
176
|
background?: BackgroundJSON;
|
|
177
177
|
canvasWidth?: number;
|
|
178
178
|
canvasHeight?: number;
|
|
179
|
+
deviceClass?: DeviceClass;
|
|
179
180
|
}): ScreenLayersJSON {
|
|
180
181
|
return {
|
|
181
182
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
182
183
|
canvasWidth: opts.canvasWidth ?? DEFAULT_CANVAS_WIDTH,
|
|
183
184
|
canvasHeight: opts.canvasHeight ?? DEFAULT_CANVAS_HEIGHT,
|
|
185
|
+
deviceClass: opts.deviceClass,
|
|
184
186
|
layers: opts.layers,
|
|
185
187
|
background: opts.background
|
|
186
188
|
};
|
package/src/compose.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { BackgroundJSON, Template } from './types';
|
|
1
|
+
import type { BackgroundJSON, LayerJSON, Template } from './types';
|
|
2
2
|
import { makeTextLayer, makeScreen, makeTemplate } from './builders';
|
|
3
3
|
import { makeDeviceFrameLayers } from './frames';
|
|
4
|
-
import { getDeviceFrame } from './device-frames';
|
|
4
|
+
import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Editor-unit canvas dimensions for a device, by frame class — so a plan that mixes iPhone, iPad,
|
|
@@ -22,10 +22,27 @@ function canvasDimsForDevice(deviceId: string): { width: number; height: number
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Screen layout variants:
|
|
27
|
+
* - `text-top` (default): headline (+ subheadline) at the top, device below, bleeding off the bottom.
|
|
28
|
+
* - `text-bottom`: device at the top bleeding off the TOP edge, text block anchored to the bottom.
|
|
29
|
+
* - `device-bleed`: text at the top, an oversized device (~95% of the width) bleeding heavily off
|
|
30
|
+
* the bottom — the "hero" look.
|
|
31
|
+
*/
|
|
32
|
+
export type ComposeLayout = 'text-top' | 'text-bottom' | 'device-bleed';
|
|
33
|
+
|
|
34
|
+
export const COMPOSE_LAYOUTS: readonly ComposeLayout[] = ['text-top', 'text-bottom', 'device-bleed'];
|
|
35
|
+
|
|
25
36
|
/** One screen's worth of plan input (the skill decides these per benefit). */
|
|
26
37
|
export interface ComposeScreenPlan {
|
|
27
38
|
headline: string;
|
|
28
39
|
headlineColor?: string;
|
|
40
|
+
/** Optional short supporting line under the headline (smaller, slightly muted). */
|
|
41
|
+
subheadline?: string;
|
|
42
|
+
/** Defaults to `headlineColor` (rendered at reduced opacity). */
|
|
43
|
+
subheadlineColor?: string;
|
|
44
|
+
/** Defaults to `text-top`. */
|
|
45
|
+
layout?: ComposeLayout;
|
|
29
46
|
background: BackgroundJSON;
|
|
30
47
|
screenshot: { url: string; width: number; height: number };
|
|
31
48
|
deviceId: string;
|
|
@@ -38,11 +55,103 @@ export interface ComposePlan {
|
|
|
38
55
|
canvasHeight?: number;
|
|
39
56
|
}
|
|
40
57
|
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Layout proportions. EVERYTHING below is a fraction of the canvas — there are no absolute pixel
|
|
60
|
+
// constants, so the same plan composes to a proportionally identical layout at 280×608 (editor
|
|
61
|
+
// units) or 1320×2868 (native iPhone 6.9" pixels).
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
41
64
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
* Typographic unit. On portrait phone canvases this is the canvas width; on squatter canvases
|
|
66
|
+
* (tablet, laptop) it's capped by the height so text doesn't swallow a landscape canvas.
|
|
67
|
+
*/
|
|
68
|
+
const TYPE_UNIT_HEIGHT_CAP = 0.55; // unit = min(W, 0.55·H)
|
|
69
|
+
const HEADLINE_SIZE = 0.085; // × unit — for headlines that fit in ≤ 2 lines
|
|
70
|
+
const HEADLINE_SIZE_LONG = 0.072; // × unit — fallback when the headline would wrap to 3+ lines
|
|
71
|
+
const HEADLINE_LINE_HEIGHT = 1.1;
|
|
72
|
+
const SUBHEADLINE_RATIO = 0.55; // subheadline size ÷ headline size
|
|
73
|
+
const SUBHEADLINE_LINE_HEIGHT = 1.25;
|
|
74
|
+
const SUBHEADLINE_OPACITY = 0.85;
|
|
75
|
+
const TEXT_WIDTH = 0.84; // × W → 8% side padding each side
|
|
76
|
+
/** Rough average glyph advance for Inter at heavy weights, as a fraction of the font size. */
|
|
77
|
+
const AVG_CHAR_WIDTH = 0.58;
|
|
78
|
+
|
|
79
|
+
const EDGE_MARGIN = 0.055; // × H — gap between the text block and the canvas edge
|
|
80
|
+
const TEXT_GAP = 0.3; // × headline font size — headline ↔ subheadline gap
|
|
81
|
+
const DEVICE_GAP = 0.04; // × unit — text block ↔ device gap
|
|
82
|
+
|
|
83
|
+
interface LayoutSpec {
|
|
84
|
+
/** Target rendered device width as a fraction of the canvas width. */
|
|
85
|
+
deviceWidth: number;
|
|
86
|
+
/** Max fraction of the device's height allowed off-canvas (bounds the scale on squat canvases). */
|
|
87
|
+
maxBleed: number;
|
|
88
|
+
/** Device can't start above this fraction of H (text-top variants) — pushes the hero lower. */
|
|
89
|
+
minDeviceTop: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const LAYOUTS: Record<ComposeLayout, LayoutSpec> = {
|
|
93
|
+
'text-top': { deviceWidth: 0.86, maxBleed: 0.2, minDeviceTop: 0 },
|
|
94
|
+
'text-bottom': { deviceWidth: 0.9, maxBleed: 0.2, minDeviceTop: 0 },
|
|
95
|
+
'device-bleed': { deviceWidth: 0.95, maxBleed: 0.4, minDeviceTop: 0.28 }
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Greedy word-wrap estimate of how many lines `text` takes at `fontSize` in a box `width` wide. */
|
|
99
|
+
function estimateLines(text: string, fontSize: number, width: number): number {
|
|
100
|
+
const maxChars = Math.max(1, Math.floor(width / (fontSize * AVG_CHAR_WIDTH)));
|
|
101
|
+
let lines = 0;
|
|
102
|
+
for (const paragraph of text.split('\n')) {
|
|
103
|
+
let current = 0;
|
|
104
|
+
lines++;
|
|
105
|
+
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
106
|
+
const len = word.length;
|
|
107
|
+
if (current === 0) {
|
|
108
|
+
current = len;
|
|
109
|
+
} else if (current + 1 + len <= maxChars) {
|
|
110
|
+
current += 1 + len;
|
|
111
|
+
} else {
|
|
112
|
+
lines++;
|
|
113
|
+
current = len;
|
|
114
|
+
}
|
|
115
|
+
// A single word longer than a line wraps mid-word in Fabric's Textbox.
|
|
116
|
+
while (current > maxChars) {
|
|
117
|
+
lines++;
|
|
118
|
+
current -= maxChars;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return Math.max(1, lines);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface TextBlock {
|
|
126
|
+
headlineSize: number;
|
|
127
|
+
headlineHeight: number;
|
|
128
|
+
subSize: number;
|
|
129
|
+
subHeight: number;
|
|
130
|
+
gap: number;
|
|
131
|
+
height: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function measureTextBlock(screen: ComposeScreenPlan, unit: number, textWidth: number): TextBlock {
|
|
135
|
+
let headlineSize = unit * HEADLINE_SIZE;
|
|
136
|
+
if (estimateLines(screen.headline, headlineSize, textWidth) > 2) headlineSize = unit * HEADLINE_SIZE_LONG;
|
|
137
|
+
const headlineHeight =
|
|
138
|
+
estimateLines(screen.headline, headlineSize, textWidth) * headlineSize * HEADLINE_LINE_HEIGHT;
|
|
139
|
+
|
|
140
|
+
const hasSub = !!screen.subheadline?.trim();
|
|
141
|
+
const subSize = headlineSize * SUBHEADLINE_RATIO;
|
|
142
|
+
const subHeight = hasSub
|
|
143
|
+
? estimateLines(screen.subheadline!, subSize, textWidth) * subSize * SUBHEADLINE_LINE_HEIGHT
|
|
144
|
+
: 0;
|
|
145
|
+
const gap = hasSub ? headlineSize * TEXT_GAP : 0;
|
|
146
|
+
return { headlineSize, headlineHeight, subSize, subHeight, gap, height: headlineHeight + gap + subHeight };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Deterministically assemble a Template from a plan. Each screen gets a background, a large
|
|
151
|
+
* device-framed screenshot and a bold headline (+ optional subheadline), arranged per the screen's
|
|
152
|
+
* `layout`. All geometry is derived from the canvas size. Text layers are marked editable so the
|
|
153
|
+
* user can tweak them in the editor. Claude decides the plan (benefit, copy, device, palette,
|
|
154
|
+
* layout); this turns it into valid DSL.
|
|
46
155
|
*/
|
|
47
156
|
export function composeTemplate(plan: ComposePlan): Template {
|
|
48
157
|
const screens = plan.screens.map((screen) => {
|
|
@@ -50,42 +159,99 @@ export function composeTemplate(plan: ComposePlan): Template {
|
|
|
50
159
|
// old single-size behavior), else derive from the screen's device class so a mixed-device
|
|
51
160
|
// plan gets the correct aspect per screen.
|
|
52
161
|
const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
|
|
53
|
-
const { width:
|
|
162
|
+
const { width: W, height: H } = explicit
|
|
54
163
|
? { width: plan.canvasWidth ?? 280, height: plan.canvasHeight ?? 600 }
|
|
55
164
|
: canvasDimsForDevice(screen.deviceId);
|
|
56
165
|
|
|
166
|
+
const layout: ComposeLayout = screen.layout ?? 'text-top';
|
|
167
|
+
const spec = LAYOUTS[layout] ?? LAYOUTS['text-top'];
|
|
168
|
+
const unit = Math.min(W, H * TYPE_UNIT_HEIGHT_CAP);
|
|
169
|
+
const textWidth = W * TEXT_WIDTH;
|
|
170
|
+
const margin = H * EDGE_MARGIN;
|
|
171
|
+
const deviceGap = unit * DEVICE_GAP;
|
|
172
|
+
const block = measureTextBlock(screen, unit, textWidth);
|
|
173
|
+
|
|
174
|
+
// Text block vertical extent.
|
|
175
|
+
const blockTop = layout === 'text-bottom' ? H - margin - block.height : margin;
|
|
176
|
+
|
|
177
|
+
// Device size: target a fraction of the canvas width, but never let more than `maxBleed` of
|
|
178
|
+
// the device fall off-canvas (keeps squat tablet/laptop canvases sane).
|
|
179
|
+
const device = getDeviceFrame(screen.deviceId);
|
|
180
|
+
if (!device) throw new Error(`Unknown device: ${screen.deviceId}`);
|
|
181
|
+
const { width: fw, height: fh } = device.imageDimensions;
|
|
182
|
+
let scale: number;
|
|
183
|
+
let centerY: number;
|
|
184
|
+
if (layout === 'text-bottom') {
|
|
185
|
+
const deviceBottom = blockTop - deviceGap;
|
|
186
|
+
scale = Math.min((W * spec.deviceWidth) / fw, deviceBottom / (1 - spec.maxBleed) / fh);
|
|
187
|
+
centerY = deviceBottom - (fh * scale) / 2; // top edge bleeds off the top when tall
|
|
188
|
+
} else {
|
|
189
|
+
const deviceTop = Math.max(margin + block.height + deviceGap, H * spec.minDeviceTop);
|
|
190
|
+
scale = Math.min((W * spec.deviceWidth) / fw, (H - deviceTop) / (1 - spec.maxBleed) / fh);
|
|
191
|
+
centerY = deviceTop + (fh * scale) / 2; // bottom edge bleeds off the bottom when tall
|
|
192
|
+
}
|
|
193
|
+
|
|
57
194
|
const { screenshot, frame } = makeDeviceFrameLayers({
|
|
58
195
|
deviceId: screen.deviceId,
|
|
59
196
|
screenshotUrl: screen.screenshot.url,
|
|
60
197
|
screenshotWidth: screen.screenshot.width,
|
|
61
198
|
screenshotHeight: screen.screenshot.height,
|
|
62
|
-
canvasWidth,
|
|
63
|
-
canvasHeight,
|
|
64
|
-
|
|
199
|
+
canvasWidth: W,
|
|
200
|
+
canvasHeight: H,
|
|
201
|
+
centerX: W / 2,
|
|
202
|
+
centerY,
|
|
203
|
+
scale
|
|
65
204
|
});
|
|
66
205
|
|
|
67
|
-
// Center origin (editor convention): left/top are the box CENTER.
|
|
68
|
-
|
|
206
|
+
// Center origin (editor convention): left/top are the box CENTER.
|
|
207
|
+
const headlineColor = screen.headlineColor ?? '#ffffff';
|
|
69
208
|
const headline = makeTextLayer({
|
|
70
209
|
text: screen.headline,
|
|
71
|
-
left:
|
|
72
|
-
top:
|
|
73
|
-
width:
|
|
74
|
-
fontSize:
|
|
210
|
+
left: W / 2,
|
|
211
|
+
top: blockTop + block.headlineHeight / 2,
|
|
212
|
+
width: textWidth,
|
|
213
|
+
fontSize: block.headlineSize,
|
|
75
214
|
fontWeight: '800',
|
|
76
|
-
|
|
215
|
+
lineHeight: HEADLINE_LINE_HEIGHT,
|
|
216
|
+
fill: headlineColor,
|
|
77
217
|
textAlign: 'center',
|
|
78
218
|
name: 'Headline',
|
|
79
219
|
templateRole: 'editable',
|
|
80
220
|
templateKey: 'headline'
|
|
81
221
|
});
|
|
82
222
|
|
|
83
|
-
|
|
223
|
+
const layers: LayerJSON[] = [screenshot, frame, headline];
|
|
224
|
+
|
|
225
|
+
if (screen.subheadline?.trim()) {
|
|
226
|
+
const sub = makeTextLayer({
|
|
227
|
+
text: screen.subheadline,
|
|
228
|
+
left: W / 2,
|
|
229
|
+
top: blockTop + block.headlineHeight + block.gap + block.subHeight / 2,
|
|
230
|
+
width: textWidth,
|
|
231
|
+
fontSize: block.subSize,
|
|
232
|
+
fontWeight: '500',
|
|
233
|
+
lineHeight: SUBHEADLINE_LINE_HEIGHT,
|
|
234
|
+
fill: screen.subheadlineColor ?? headlineColor,
|
|
235
|
+
textAlign: 'center',
|
|
236
|
+
name: 'Subheadline',
|
|
237
|
+
templateRole: 'editable',
|
|
238
|
+
templateKey: 'subheadline'
|
|
239
|
+
});
|
|
240
|
+
// When it inherits the headline color, mute it slightly (standard Fabric `opacity`, editable
|
|
241
|
+
// in the editor). An explicit subheadlineColor is used as-is.
|
|
242
|
+
if (!screen.subheadlineColor) (sub.fabricData as Record<string, unknown>).opacity = SUBHEADLINE_OPACITY;
|
|
243
|
+
layers.push(sub);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// z-order: screenshot (bottom) -> frame -> headline -> subheadline (top)
|
|
84
247
|
return makeScreen({
|
|
85
248
|
background: screen.background,
|
|
86
|
-
canvasWidth,
|
|
87
|
-
canvasHeight,
|
|
88
|
-
|
|
249
|
+
canvasWidth: W,
|
|
250
|
+
canvasHeight: H,
|
|
251
|
+
// Tag the device GROUP (multi-device) so a mixed plan lands as separate sidebar groups in
|
|
252
|
+
// the editor instead of relying on frame inference. Absent ⇒ editor infers it.
|
|
253
|
+
deviceClass: deviceClassForDeviceId(screen.deviceId) ?? undefined,
|
|
254
|
+
layers
|
|
89
255
|
});
|
|
90
256
|
});
|
|
91
257
|
|
package/src/device-frames.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DeviceClass } from './types';
|
|
2
|
+
|
|
1
3
|
// =============================================================================
|
|
2
4
|
// DEVICE FRAME DEFINITIONS
|
|
3
5
|
// =============================================================================
|
|
@@ -316,3 +318,16 @@ export function getDeviceFramesByPlatform(platform: DeviceFrame['platform']): De
|
|
|
316
318
|
export function getDeviceFramesByCategory(category: DeviceFrame['category']): DeviceFrame[] {
|
|
317
319
|
return deviceFrames.filter((d) => d.category === category);
|
|
318
320
|
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Map a device-frame id to its device GROUP (multi-device), using the frame's platform + category.
|
|
324
|
+
* Returns null for an unknown id. Kept in sync with the app's `device-groups.ts` mapping.
|
|
325
|
+
*/
|
|
326
|
+
export function deviceClassForDeviceId(deviceId: string): DeviceClass | null {
|
|
327
|
+
const frame = getDeviceFrame(deviceId);
|
|
328
|
+
if (!frame) return null;
|
|
329
|
+
if (frame.platform === 'ios') return frame.category === 'tablet' ? 'ipad_13' : 'iphone_6_9';
|
|
330
|
+
if (frame.platform === 'android') return 'android_phone';
|
|
331
|
+
if (frame.platform === 'macos' || frame.platform === 'windows') return 'macbook';
|
|
332
|
+
return null;
|
|
333
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -48,11 +48,19 @@ export interface BackgroundJSON {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Device GROUP a screen belongs to (multi-device). Mirrors the app's `db.ts` union so a composed
|
|
53
|
+
* template can tag each screen and the editor buckets it into the right sidebar group on import.
|
|
54
|
+
*/
|
|
55
|
+
export type DeviceClass = 'iphone_6_9' | 'ipad_13' | 'android_phone' | 'macbook';
|
|
56
|
+
|
|
51
57
|
/** One screen (canvas) of a design. */
|
|
52
58
|
export interface ScreenLayersJSON {
|
|
53
59
|
schemaVersion: number;
|
|
54
60
|
canvasWidth?: number;
|
|
55
61
|
canvasHeight?: number;
|
|
62
|
+
/** Device group (multi-device); optional/additive — absent ⇒ the editor infers it. */
|
|
63
|
+
deviceClass?: DeviceClass;
|
|
56
64
|
layers: LayerJSON[];
|
|
57
65
|
background?: BackgroundJSON;
|
|
58
66
|
}
|