@appshoteditor/shot-dsl 0.1.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 ADDED
@@ -0,0 +1,39 @@
1
+ # @appshoteditor/shot-dsl
2
+
3
+ The layout DSL behind [appshoteditor.com](https://appshoteditor.com) — framework-free building blocks
4
+ for composing **editable App Store / Play Store screenshot layouts**, plus device-frame geometry.
5
+
6
+ It is the single source of truth shared by the web editor, the Cloudflare Worker (handoff validation),
7
+ and the [App Store Screenshots agent skill](https://github.com/AppShotEditor/app-store-screenshots-skill)'s
8
+ composer — so a layout composed by the skill renders identically in the editor.
9
+
10
+ ## What's inside
11
+
12
+ - **Types** — `Template`, `ScreenLayersJSON`, `LayerJSON`, `BackgroundJSON`, `CURRENT_SCHEMA_VERSION`.
13
+ - **Validators** — `validateTemplate()` (strict, error-reporting), `isValidTemplate`, `migrateScreenLayersJSON`.
14
+ - **Builders** — `makeTextLayer`, `makeImageLayer`, `makeShapeLayer`, `makeSolid/GradientBackground`,
15
+ `makeScreen`, `makeTemplate`.
16
+ - **Device geometry** — `deviceFrames`, `getDeviceFrame`, `makeDeviceFrameLayers`, `calculateDeviceScale`.
17
+ - **Composer** — `composeTemplate(plan)`: a benefit/screenshot plan → a validated, device-framed `Template`.
18
+
19
+ ```ts
20
+ import { composeTemplate, validateTemplate } from '@appshoteditor/shot-dsl';
21
+
22
+ const template = composeTemplate({
23
+ name: 'My App',
24
+ screens: [
25
+ {
26
+ headline: 'Track every workout',
27
+ background: { type: 'gradient', gradient: { type: 'linear', colorStops: [/* … */] } },
28
+ deviceId: 'iphone_16_pro',
29
+ screenshot: { url: '…', width: 1179, height: 2556 }
30
+ }
31
+ ]
32
+ });
33
+ validateTemplate(template); // { valid: true, errors: [] }
34
+ ```
35
+
36
+ Zero runtime dependencies. The `schemaVersion` is the compatibility contract between producers and the editor.
37
+
38
+ > Intended for use via a bundler (Vite, esbuild, etc.). Device `frameAsset` values are URL paths the
39
+ > editor serves; this package ships only the geometry, not the frame images.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@appshoteditor/shot-dsl",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "module": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.ts",
13
+ "default": "./src/index.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "src/index.ts",
18
+ "src/types.ts",
19
+ "src/validate.ts",
20
+ "src/builders.ts",
21
+ "src/compose.ts",
22
+ "src/frames.ts",
23
+ "src/device-frames.ts"
24
+ ],
25
+ "sideEffects": false,
26
+ "keywords": [
27
+ "app-store-screenshots",
28
+ "appshoteditor",
29
+ "agent-skill",
30
+ "device-frames",
31
+ "layout-dsl"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/AppShotEditor/app-shot-editor.git",
36
+ "directory": "packages/shot-dsl"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
@@ -0,0 +1,210 @@
1
+ import type { BackgroundJSON, ColorStop, LayerJSON, ScreenLayersJSON, Template, TemplateRole } from './types';
2
+ import { CURRENT_SCHEMA_VERSION } from './types';
3
+ import { generateLayerId } from './validate';
4
+
5
+ /** Common per-layer metadata accepted by every builder. */
6
+ interface LayerMeta {
7
+ id?: string;
8
+ name?: string;
9
+ visible?: boolean;
10
+ locked?: boolean;
11
+ templateRole?: TemplateRole;
12
+ templateKey?: string;
13
+ }
14
+
15
+ const DEFAULT_CANVAS_WIDTH = 280;
16
+ const DEFAULT_CANVAS_HEIGHT = 600;
17
+
18
+ // ---- Backgrounds ----
19
+
20
+ export function makeSolidBackground(color: string): BackgroundJSON {
21
+ return { type: 'solid', color };
22
+ }
23
+
24
+ export function makeGradientBackground(opts: {
25
+ colorStops: ColorStop[];
26
+ type?: 'linear' | 'radial';
27
+ coords?: { x1: number; y1: number; x2: number; y2: number };
28
+ }): BackgroundJSON {
29
+ return {
30
+ type: 'gradient',
31
+ gradient: { type: opts.type ?? 'linear', colorStops: opts.colorStops, coords: opts.coords }
32
+ };
33
+ }
34
+
35
+ // ---- Layers ----
36
+
37
+ export function makeTextLayer(
38
+ opts: {
39
+ text: string;
40
+ left: number;
41
+ top: number;
42
+ width: number;
43
+ fontSize?: number;
44
+ fontFamily?: string;
45
+ fontWeight?: string;
46
+ fill?: string;
47
+ textAlign?: 'left' | 'center' | 'right';
48
+ lineHeight?: number;
49
+ originX?: 'left' | 'center' | 'right';
50
+ originY?: 'top' | 'center' | 'bottom';
51
+ } & LayerMeta
52
+ ): LayerJSON {
53
+ const id = opts.id ?? generateLayerId();
54
+ return {
55
+ id,
56
+ name: opts.name ?? 'Text',
57
+ type: 'text',
58
+ visible: opts.visible ?? true,
59
+ locked: opts.locked ?? false,
60
+ templateRole: opts.templateRole,
61
+ templateKey: opts.templateKey,
62
+ fabricData: {
63
+ type: 'Textbox',
64
+ left: opts.left,
65
+ top: opts.top,
66
+ width: opts.width,
67
+ text: opts.text,
68
+ fill: opts.fill ?? '#ffffff',
69
+ fontSize: opts.fontSize ?? 28,
70
+ fontFamily: opts.fontFamily ?? 'Inter',
71
+ fontWeight: opts.fontWeight ?? '700',
72
+ textAlign: opts.textAlign ?? 'center',
73
+ lineHeight: opts.lineHeight ?? 1.1,
74
+ // Match the editor convention (addText, makeImageLayer, makeShapeLayer all
75
+ // use center origin) so `left`/`top` are the box center, not its corner.
76
+ originX: opts.originX ?? 'center',
77
+ originY: opts.originY ?? 'center',
78
+ layerId: id,
79
+ layerType: 'text'
80
+ }
81
+ };
82
+ }
83
+
84
+ export function makeImageLayer(
85
+ opts: {
86
+ src: string;
87
+ left: number;
88
+ top: number;
89
+ width: number;
90
+ height: number;
91
+ scaleX?: number;
92
+ scaleY?: number;
93
+ originX?: 'left' | 'center' | 'right';
94
+ originY?: 'top' | 'center' | 'bottom';
95
+ imageCornerRadius?: number;
96
+ } & LayerMeta
97
+ ): LayerJSON {
98
+ const id = opts.id ?? generateLayerId();
99
+ return {
100
+ id,
101
+ name: opts.name ?? 'Image',
102
+ type: 'image',
103
+ visible: opts.visible ?? true,
104
+ locked: opts.locked ?? false,
105
+ templateRole: opts.templateRole,
106
+ templateKey: opts.templateKey,
107
+ fabricData: {
108
+ type: 'image',
109
+ left: opts.left,
110
+ top: opts.top,
111
+ width: opts.width,
112
+ height: opts.height,
113
+ scaleX: opts.scaleX ?? 1,
114
+ scaleY: opts.scaleY ?? 1,
115
+ src: opts.src,
116
+ originX: opts.originX ?? 'center',
117
+ originY: opts.originY ?? 'center',
118
+ ...(opts.imageCornerRadius ? { imageCornerRadius: opts.imageCornerRadius } : {}),
119
+ layerId: id,
120
+ layerType: 'image'
121
+ }
122
+ };
123
+ }
124
+
125
+ export function makeShapeLayer(
126
+ opts: {
127
+ shape: 'rectangle' | 'circle';
128
+ left: number;
129
+ top: number;
130
+ width?: number;
131
+ height?: number;
132
+ radius?: number;
133
+ fill?: string;
134
+ rx?: number;
135
+ ry?: number;
136
+ } & LayerMeta
137
+ ): LayerJSON {
138
+ const id = opts.id ?? generateLayerId();
139
+ const common = {
140
+ left: opts.left,
141
+ top: opts.top,
142
+ fill: opts.fill ?? '#0ea5e9',
143
+ originX: 'center',
144
+ originY: 'center',
145
+ layerId: id,
146
+ layerType: 'shape'
147
+ };
148
+ const fabricData =
149
+ opts.shape === 'circle'
150
+ ? { type: 'circle', radius: opts.radius ?? 50, ...common, shapeType: 'circle' }
151
+ : {
152
+ type: 'Rect',
153
+ width: opts.width ?? 100,
154
+ height: opts.height ?? 100,
155
+ rx: opts.rx ?? 0,
156
+ ry: opts.ry ?? 0,
157
+ ...common,
158
+ shapeType: 'rectangle'
159
+ };
160
+ return {
161
+ id,
162
+ name: opts.name ?? 'Shape',
163
+ type: 'shape',
164
+ visible: opts.visible ?? true,
165
+ locked: opts.locked ?? false,
166
+ templateRole: opts.templateRole,
167
+ templateKey: opts.templateKey,
168
+ fabricData
169
+ };
170
+ }
171
+
172
+ // ---- Assembly ----
173
+
174
+ export function makeScreen(opts: {
175
+ layers: LayerJSON[];
176
+ background?: BackgroundJSON;
177
+ canvasWidth?: number;
178
+ canvasHeight?: number;
179
+ }): ScreenLayersJSON {
180
+ return {
181
+ schemaVersion: CURRENT_SCHEMA_VERSION,
182
+ canvasWidth: opts.canvasWidth ?? DEFAULT_CANVAS_WIDTH,
183
+ canvasHeight: opts.canvasHeight ?? DEFAULT_CANVAS_HEIGHT,
184
+ layers: opts.layers,
185
+ background: opts.background
186
+ };
187
+ }
188
+
189
+ export function makeTemplate(opts: {
190
+ id?: string;
191
+ name: string;
192
+ screens: ScreenLayersJSON[];
193
+ description?: string;
194
+ thumbnail?: string;
195
+ tags?: string[];
196
+ version?: string;
197
+ author?: string;
198
+ }): Template {
199
+ return {
200
+ id: opts.id ?? generateLayerId(),
201
+ name: opts.name,
202
+ description: opts.description,
203
+ thumbnail: opts.thumbnail ?? '',
204
+ tags: opts.tags ?? [],
205
+ version: opts.version ?? '1.0.0',
206
+ screens: opts.screens,
207
+ createdAt: new Date().toISOString(),
208
+ author: opts.author
209
+ };
210
+ }
package/src/compose.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { BackgroundJSON, Template } from './types';
2
+ import { makeTextLayer, makeScreen, makeTemplate } from './builders';
3
+ import { makeDeviceFrameLayers } from './frames';
4
+
5
+ /** One screen's worth of plan input (the skill decides these per benefit). */
6
+ export interface ComposeScreenPlan {
7
+ headline: string;
8
+ headlineColor?: string;
9
+ background: BackgroundJSON;
10
+ screenshot: { url: string; width: number; height: number };
11
+ deviceId: string;
12
+ }
13
+
14
+ export interface ComposePlan {
15
+ name: string;
16
+ screens: ComposeScreenPlan[];
17
+ canvasWidth?: number;
18
+ canvasHeight?: number;
19
+ }
20
+
21
+ /**
22
+ * Deterministically assemble a Template from a plan. Each screen gets a background,
23
+ * a device-framed screenshot sitting in the lower ~60%, and a headline across the
24
+ * top (marked editable so the user can tweak it in the editor). Claude decides the
25
+ * plan (which benefit, copy, device, palette); this turns it into valid DSL.
26
+ */
27
+ export function composeTemplate(plan: ComposePlan): Template {
28
+ const canvasWidth = plan.canvasWidth ?? 280;
29
+ const canvasHeight = plan.canvasHeight ?? 600;
30
+
31
+ const screens = plan.screens.map((screen) => {
32
+ const { screenshot, frame } = makeDeviceFrameLayers({
33
+ deviceId: screen.deviceId,
34
+ screenshotUrl: screen.screenshot.url,
35
+ screenshotWidth: screen.screenshot.width,
36
+ screenshotHeight: screen.screenshot.height,
37
+ canvasWidth,
38
+ canvasHeight,
39
+ centerY: canvasHeight * 0.6 // sit the device lower, leaving room for the headline
40
+ });
41
+
42
+ // Center origin (editor convention): left/top are the box CENTER. Center the
43
+ // headline horizontally and sit it near the top, leaving room for the device.
44
+ const headline = makeTextLayer({
45
+ text: screen.headline,
46
+ left: canvasWidth / 2,
47
+ top: canvasHeight * 0.12,
48
+ width: canvasWidth * 0.84,
49
+ fontSize: 26,
50
+ fontWeight: '800',
51
+ fill: screen.headlineColor ?? '#ffffff',
52
+ textAlign: 'center',
53
+ name: 'Headline',
54
+ templateRole: 'editable',
55
+ templateKey: 'headline'
56
+ });
57
+
58
+ // z-order: screenshot (bottom) -> frame -> headline (top)
59
+ return makeScreen({
60
+ background: screen.background,
61
+ canvasWidth,
62
+ canvasHeight,
63
+ layers: [screenshot, frame, headline]
64
+ });
65
+ });
66
+
67
+ return makeTemplate({ name: plan.name, screens, tags: ['generated'] });
68
+ }
@@ -0,0 +1,318 @@
1
+ // =============================================================================
2
+ // DEVICE FRAME DEFINITIONS
3
+ // =============================================================================
4
+ //
5
+ // HOW TO ADD/EDIT A DEVICE:
6
+ //
7
+ // 1. Open the device PNG in Affinity Designer (or similar)
8
+ // 2. Get the document size → imageDimensions: { width, height }
9
+ // 3. Draw a rectangle over the transparent screen area
10
+ // 4. From Transform panel, copy X, Y, W, H → screenBounds: { x, y, width, height }
11
+ // 5. Add entry below
12
+ //
13
+ // =============================================================================
14
+
15
+ export interface DeviceFrame {
16
+ id: string;
17
+ name: string;
18
+ platform: 'ios' | 'android' | 'macos' | 'windows';
19
+ category: 'phone' | 'tablet' | 'desktop' | 'laptop';
20
+ frameAsset: string; // Path to PNG with transparent screen
21
+ imageDimensions: { width: number; height: number }; // Size of the PNG
22
+ screenBounds: { x: number; y: number; width: number; height: number }; // Screen area within PNG
23
+ cornerRadius: number; // Corner radius of the screen in pixels (unscaled)
24
+ }
25
+
26
+ // =============================================================================
27
+ // DEVICE LIST - Edit this array to add/modify devices
28
+ // =============================================================================
29
+
30
+ export const deviceFrames: DeviceFrame[] = [
31
+ // -------------------------------------------------------------------------
32
+ // iOS PHONES
33
+ // -------------------------------------------------------------------------
34
+ {
35
+ id: 'iphone_16_pro',
36
+ name: 'iPhone 16 Pro',
37
+ platform: 'ios',
38
+ category: 'phone',
39
+ frameAsset: '/devices/iphone-16-pro.png',
40
+ imageDimensions: { width: 1406, height: 2822 },
41
+ // Screen centered within frame: (1406-1212)/2=97, (2822-2618)/2=102
42
+ screenBounds: { x: 97, y: 100, width: 1212, height: 2624 },
43
+ cornerRadius: 120 // Adjust to match device screen corners
44
+ },
45
+ {
46
+ id: 'iphone_16_pro_max',
47
+ name: 'iPhone 16 Pro Max',
48
+ platform: 'ios',
49
+ category: 'phone',
50
+ frameAsset: '/devices/iphone-16-pro-max.png',
51
+ imageDimensions: { width: 1520, height: 3068 },
52
+ // Screen centered within frame: (1520-1310)/2=105, (3068-2846)/2=111
53
+ screenBounds: { x: 100, y: 100, width: 1320, height: 2870 },
54
+ cornerRadius: 140 // Slightly larger than 16 Pro
55
+ },
56
+ {
57
+ id: 'iphone_16',
58
+ name: 'iPhone 16',
59
+ platform: 'ios',
60
+ category: 'phone',
61
+ frameAsset: '/devices/iphone-16.png',
62
+ imageDimensions: { width: 1379, height: 2756 },
63
+ screenBounds: { x: 95, y: 98, width: 1189, height: 2563 },
64
+ cornerRadius: 118
65
+ },
66
+ {
67
+ id: 'iphone_16_plus',
68
+ name: 'iPhone 16 Plus',
69
+ platform: 'ios',
70
+ category: 'phone',
71
+ frameAsset: '/devices/iphone-16-plus.png',
72
+ imageDimensions: { width: 1490, height: 2996 },
73
+ screenBounds: { x: 103, y: 106, width: 1284, height: 2786 },
74
+ cornerRadius: 128
75
+ },
76
+ {
77
+ id: 'iphone_17_pro',
78
+ name: 'iPhone 17 Pro',
79
+ platform: 'ios',
80
+ category: 'phone',
81
+ frameAsset: '/devices/iphone-17-pro.png',
82
+ imageDimensions: { width: 1406, height: 2822 },
83
+ screenBounds: { x: 97, y: 100, width: 1212, height: 2624 },
84
+ cornerRadius: 120
85
+ },
86
+ {
87
+ id: 'iphone_17_pro_max',
88
+ name: 'iPhone 17 Pro Max',
89
+ platform: 'ios',
90
+ category: 'phone',
91
+ frameAsset: '/devices/iphone-17-pro-max.png',
92
+ imageDimensions: { width: 1520, height: 3068 },
93
+ screenBounds: { x: 100, y: 100, width: 1320, height: 2870 },
94
+ cornerRadius: 140
95
+ },
96
+ {
97
+ id: 'iphone_air',
98
+ name: 'iPhone Air',
99
+ platform: 'ios',
100
+ category: 'phone',
101
+ frameAsset: '/devices/iphone-air.png',
102
+ imageDimensions: { width: 1490, height: 2996 },
103
+ screenBounds: { x: 103, y: 106, width: 1284, height: 2786 },
104
+ cornerRadius: 128
105
+ },
106
+
107
+ // -------------------------------------------------------------------------
108
+ // ANDROID PHONES
109
+ // -------------------------------------------------------------------------
110
+ {
111
+ id: 'pixel_9_pro',
112
+ name: 'Google Pixel 9 Pro',
113
+ platform: 'android',
114
+ category: 'phone',
115
+ frameAsset: '/devices/pixel-9-pro.png',
116
+ imageDimensions: { width: 1620, height: 3136 },
117
+ screenBounds: { x: 112, y: 111, width: 1396, height: 2916 },
118
+ cornerRadius: 135
119
+ },
120
+ {
121
+ id: 'pixel_9_pro_xl',
122
+ name: 'Google Pixel 9 Pro XL',
123
+ platform: 'android',
124
+ category: 'phone',
125
+ frameAsset: '/devices/pixel-9-pro-xl.png',
126
+ imageDimensions: { width: 1684, height: 3272 },
127
+ screenBounds: { x: 116, y: 116, width: 1452, height: 3043 },
128
+ cornerRadius: 140
129
+ },
130
+
131
+ // -------------------------------------------------------------------------
132
+ // iPadOS TABLETS
133
+ // -------------------------------------------------------------------------
134
+ {
135
+ id: 'ipad_pro_13_m4',
136
+ name: 'iPad Pro 13" (M4)',
137
+ platform: 'ios',
138
+ category: 'tablet',
139
+ frameAsset: '/devices/ipad-pro-13-m4.png',
140
+ imageDimensions: { width: 2264, height: 2952 },
141
+ screenBounds: { x: 100, y: 100, width: 2064, height: 2752 },
142
+ cornerRadius: 40
143
+ },
144
+ {
145
+ id: 'ipad_pro_11_m4',
146
+ name: 'iPad Pro 11" (M4)',
147
+ platform: 'ios',
148
+ category: 'tablet',
149
+ frameAsset: '/devices/ipad-pro-11-m4.png',
150
+ imageDimensions: { width: 1868, height: 2620 },
151
+ screenBounds: { x: 100, y: 116, width: 1668, height: 2388 },
152
+ cornerRadius: 40
153
+ },
154
+ {
155
+ id: 'ipad_air_13',
156
+ name: 'iPad Air 13"',
157
+ platform: 'ios',
158
+ category: 'tablet',
159
+ frameAsset: '/devices/ipad-air-13.png',
160
+ imageDimensions: { width: 2248, height: 2932 },
161
+ screenBounds: { x: 100, y: 100, width: 2048, height: 2732 },
162
+ cornerRadius: 40
163
+ },
164
+ {
165
+ id: 'ipad_air_11',
166
+ name: 'iPad Air 11"',
167
+ platform: 'ios',
168
+ category: 'tablet',
169
+ frameAsset: '/devices/ipad-air-11.png',
170
+ imageDimensions: { width: 1880, height: 2600 },
171
+ screenBounds: { x: 120, y: 120, width: 1640, height: 2360 },
172
+ cornerRadius: 40
173
+ },
174
+ {
175
+ id: 'ipad_mini_7',
176
+ name: 'iPad mini 7',
177
+ platform: 'ios',
178
+ category: 'tablet',
179
+ frameAsset: '/devices/ipad-mini-7.png',
180
+ imageDimensions: { width: 1888, height: 2666 },
181
+ screenBounds: { x: 200, y: 200, width: 1488, height: 2266 },
182
+ cornerRadius: 40
183
+ },
184
+
185
+ // -------------------------------------------------------------------------
186
+ // macOS LAPTOPS
187
+ // -------------------------------------------------------------------------
188
+ {
189
+ id: 'macbook_air_13',
190
+ name: 'MacBook Air 13"',
191
+ platform: 'macos',
192
+ category: 'laptop',
193
+ frameAsset: '/devices/macbook-air-13.png',
194
+ imageDimensions: { width: 3260, height: 2164 },
195
+ screenBounds: { x: 350, y: 306, width: 2560, height: 1608 },
196
+ cornerRadius: 20
197
+ },
198
+ {
199
+ id: 'macbook_air_13_menu_bar',
200
+ name: 'MacBook Air 13" (Menu Bar)',
201
+ platform: 'macos',
202
+ category: 'laptop',
203
+ frameAsset: '/devices/macbook-air-13-menu-bar.png',
204
+ imageDimensions: { width: 3260, height: 2164 },
205
+ screenBounds: { x: 350, y: 312, width: 2560, height: 1602 },
206
+ cornerRadius: 20
207
+ },
208
+ {
209
+ id: 'macbook_air_15',
210
+ name: 'MacBook Air 15"',
211
+ platform: 'macos',
212
+ category: 'laptop',
213
+ frameAsset: '/devices/macbook-air-15.png',
214
+ imageDimensions: { width: 3580, height: 2364 },
215
+ screenBounds: { x: 350, y: 306, width: 2880, height: 1808 },
216
+ cornerRadius: 20
217
+ },
218
+ {
219
+ id: 'macbook_air_15_menu_bar',
220
+ name: 'MacBook Air 15" (Menu Bar)',
221
+ platform: 'macos',
222
+ category: 'laptop',
223
+ frameAsset: '/devices/macbook-air-15-menu-bar.png',
224
+ imageDimensions: { width: 3580, height: 2364 },
225
+ screenBounds: { x: 350, y: 308, width: 2880, height: 1806 },
226
+ cornerRadius: 20
227
+ },
228
+ {
229
+ id: 'macbook_pro_14',
230
+ name: 'MacBook Pro 14"',
231
+ platform: 'macos',
232
+ category: 'laptop',
233
+ frameAsset: '/devices/macbook-pro-14.png',
234
+ imageDimensions: { width: 3944, height: 2564 },
235
+ screenBounds: { x: 461, y: 364, width: 3022, height: 1900 },
236
+ cornerRadius: 20
237
+ },
238
+ {
239
+ id: 'macbook_pro_14_menu_bar',
240
+ name: 'MacBook Pro 14" (Menu Bar)',
241
+ platform: 'macos',
242
+ category: 'laptop',
243
+ frameAsset: '/devices/macbook-pro-14-menu-bar.png',
244
+ imageDimensions: { width: 3824, height: 2564 },
245
+ screenBounds: { x: 401, y: 374, width: 3022, height: 1890 },
246
+ cornerRadius: 20
247
+ },
248
+ {
249
+ id: 'macbook_pro_16',
250
+ name: 'MacBook Pro 16"',
251
+ platform: 'macos',
252
+ category: 'laptop',
253
+ frameAsset: '/devices/macbook-pro-16.png',
254
+ imageDimensions: { width: 4340, height: 2860 },
255
+ screenBounds: { x: 442, y: 377, width: 3456, height: 2170 },
256
+ cornerRadius: 20
257
+ },
258
+ {
259
+ id: 'macbook_pro_16_menu_bar',
260
+ name: 'MacBook Pro 16" (Menu Bar)',
261
+ platform: 'macos',
262
+ category: 'laptop',
263
+ frameAsset: '/devices/macbook-pro-16-menu-bar.png',
264
+ imageDimensions: { width: 4340, height: 2860 },
265
+ screenBounds: { x: 442, y: 389, width: 3456, height: 2158 },
266
+ cornerRadius: 20
267
+ },
268
+
269
+ // -------------------------------------------------------------------------
270
+ // macOS DESKTOPS
271
+ // -------------------------------------------------------------------------
272
+ {
273
+ id: 'imac_24',
274
+ name: 'iMac 24"',
275
+ platform: 'macos',
276
+ category: 'desktop',
277
+ frameAsset: '/devices/imac-24.png',
278
+ imageDimensions: { width: 4880, height: 5720 },
279
+ screenBounds: { x: 200, y: 1600, width: 4480, height: 2520 },
280
+ cornerRadius: 0
281
+ },
282
+ {
283
+ id: 'studio_display',
284
+ name: 'Studio Display',
285
+ platform: 'macos',
286
+ category: 'desktop',
287
+ frameAsset: '/devices/studio-display.png',
288
+ imageDimensions: { width: 5520, height: 4316 },
289
+ screenBounds: { x: 200, y: 200, width: 5120, height: 2880 },
290
+ cornerRadius: 0
291
+ },
292
+ {
293
+ id: 'pro_display_xdr',
294
+ name: 'Pro Display XDR',
295
+ platform: 'macos',
296
+ category: 'desktop',
297
+ frameAsset: '/devices/pro-display-xdr.png',
298
+ imageDimensions: { width: 6416, height: 4865 },
299
+ screenBounds: { x: 200, y: 200, width: 6016, height: 3384 },
300
+ cornerRadius: 0
301
+ }
302
+ ];
303
+
304
+ // =============================================================================
305
+ // HELPER FUNCTIONS
306
+ // =============================================================================
307
+
308
+ export function getDeviceFrame(id: string): DeviceFrame | undefined {
309
+ return deviceFrames.find((d) => d.id === id);
310
+ }
311
+
312
+ export function getDeviceFramesByPlatform(platform: DeviceFrame['platform']): DeviceFrame[] {
313
+ return deviceFrames.filter((d) => d.platform === platform);
314
+ }
315
+
316
+ export function getDeviceFramesByCategory(category: DeviceFrame['category']): DeviceFrame[] {
317
+ return deviceFrames.filter((d) => d.category === category);
318
+ }
package/src/frames.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { getDeviceFrame, type DeviceFrame } from './device-frames';
2
+ import type { LayerJSON } from './types';
3
+ import { generateLayerId } from './validate';
4
+
5
+ const DEFAULT_CANVAS_WIDTH = 280;
6
+ const DEFAULT_CANVAS_HEIGHT = 600;
7
+
8
+ /**
9
+ * Mirror of FabricCanvas.calculateDeviceScale(): fit the device PNG within 85% of
10
+ * the canvas, capped at 0.15. Kept identical so composed frames size like the editor's.
11
+ */
12
+ export function calculateDeviceScale(
13
+ device: DeviceFrame,
14
+ canvasWidth = DEFAULT_CANVAS_WIDTH,
15
+ canvasHeight = DEFAULT_CANVAS_HEIGHT
16
+ ): number {
17
+ const maxWidth = canvasWidth * 0.85;
18
+ const maxHeight = canvasHeight * 0.85;
19
+ return Math.min(
20
+ maxWidth / device.imageDimensions.width,
21
+ maxHeight / device.imageDimensions.height,
22
+ 0.15
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Build the paired { screenshot, frame } layers for a device mockup — a Fabric-free
28
+ * reproduction of FabricCanvas.addDeviceFrame(). Place them in a screen's `layers`
29
+ * array in this order (screenshot below the frame).
30
+ *
31
+ * `screenshotWidth`/`screenshotHeight` are the screenshot's natural pixel size
32
+ * (the composer supplies them from the uploaded asset's stored dimensions).
33
+ *
34
+ * NOTE: geometry matches the editor exactly; the precise Fabric serialization shape
35
+ * (esp. clipPath) should be confirmed by round-tripping a composed device template
36
+ * through the editor before the skill relies on it in production.
37
+ */
38
+ export function makeDeviceFrameLayers(opts: {
39
+ deviceId: string;
40
+ screenshotUrl: string;
41
+ screenshotWidth: number;
42
+ screenshotHeight: number;
43
+ canvasWidth?: number;
44
+ canvasHeight?: number;
45
+ centerX?: number;
46
+ centerY?: number;
47
+ scale?: number;
48
+ screenshotRotation?: number;
49
+ name?: string;
50
+ }): { screenshot: LayerJSON; frame: LayerJSON } {
51
+ const device = getDeviceFrame(opts.deviceId);
52
+ if (!device) throw new Error(`Unknown device: ${opts.deviceId}`);
53
+
54
+ const canvasWidth = opts.canvasWidth ?? DEFAULT_CANVAS_WIDTH;
55
+ const canvasHeight = opts.canvasHeight ?? DEFAULT_CANVAS_HEIGHT;
56
+ const scale = opts.scale ?? calculateDeviceScale(device, canvasWidth, canvasHeight);
57
+
58
+ const frameId = `device-frame-${generateLayerId()}`;
59
+ const screenshotId = generateLayerId();
60
+
61
+ const scaledImageWidth = device.imageDimensions.width * scale;
62
+ const scaledImageHeight = device.imageDimensions.height * scale;
63
+ const screenWidth = device.screenBounds.width * scale;
64
+ const screenHeight = device.screenBounds.height * scale;
65
+ const screenOffsetX = device.screenBounds.x * scale;
66
+ const screenOffsetY = device.screenBounds.y * scale;
67
+ const cornerRadius = device.cornerRadius * scale;
68
+
69
+ const frameCenterX = opts.centerX ?? canvasWidth / 2;
70
+ const frameCenterY = opts.centerY ?? canvasHeight / 2;
71
+
72
+ // Screen center relative to the frame center (same formula as addDeviceFrame).
73
+ const screenCenterX = frameCenterX - scaledImageWidth / 2 + screenOffsetX + screenWidth / 2;
74
+ const screenCenterY = frameCenterY - scaledImageHeight / 2 + screenOffsetY + screenHeight / 2;
75
+
76
+ // Scale the screenshot to exactly fill the screen bounds.
77
+ const imgScaleX = screenWidth / opts.screenshotWidth;
78
+ const imgScaleY = screenHeight / opts.screenshotHeight;
79
+
80
+ const screenshot: LayerJSON = {
81
+ id: screenshotId,
82
+ name: opts.name ? `${opts.name} screenshot` : 'Screenshot',
83
+ type: 'image',
84
+ visible: true,
85
+ locked: false,
86
+ fabricData: {
87
+ type: 'image',
88
+ src: opts.screenshotUrl,
89
+ crossOrigin: 'anonymous',
90
+ left: screenCenterX,
91
+ top: screenCenterY,
92
+ width: opts.screenshotWidth,
93
+ height: opts.screenshotHeight,
94
+ scaleX: imgScaleX,
95
+ scaleY: imgScaleY,
96
+ originX: 'center',
97
+ originY: 'center',
98
+ selectable: false,
99
+ evented: false,
100
+ // clipPath is in the image's local (unscaled) coordinate space.
101
+ clipPath: {
102
+ type: 'Rect',
103
+ width: opts.screenshotWidth,
104
+ height: opts.screenshotHeight,
105
+ rx: cornerRadius / imgScaleX,
106
+ ry: cornerRadius / imgScaleY,
107
+ left: 0,
108
+ top: 0,
109
+ originX: 'center',
110
+ originY: 'center'
111
+ },
112
+ layerId: screenshotId,
113
+ layerType: 'image',
114
+ deviceFrameId: frameId,
115
+ layerRole: 'screenshot',
116
+ deviceId: opts.deviceId,
117
+ screenshotRotation: opts.screenshotRotation ?? 0
118
+ }
119
+ };
120
+
121
+ const frame: LayerJSON = {
122
+ id: frameId,
123
+ name: opts.name ?? device.name,
124
+ type: 'device',
125
+ visible: true,
126
+ locked: false,
127
+ fabricData: {
128
+ type: 'image',
129
+ src: device.frameAsset,
130
+ crossOrigin: 'anonymous',
131
+ left: frameCenterX,
132
+ top: frameCenterY,
133
+ width: device.imageDimensions.width,
134
+ height: device.imageDimensions.height,
135
+ scaleX: scale,
136
+ scaleY: scale,
137
+ originX: 'center',
138
+ originY: 'center',
139
+ layerId: frameId,
140
+ layerType: 'deviceFrame',
141
+ deviceFrameId: frameId,
142
+ layerRole: 'frame',
143
+ deviceId: opts.deviceId,
144
+ deviceScale: scale
145
+ }
146
+ };
147
+
148
+ return { screenshot, frame };
149
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // App Shot Editor layout DSL — single source of truth shared by the editor,
2
+ // the Cloudflare Worker (handoff validation), and the skill's composer.
3
+ export * from './types';
4
+ export * from './validate';
5
+ export * from './builders';
6
+ export * from './device-frames';
7
+ export * from './frames';
8
+ export * from './compose';
package/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * App Shot Editor layout DSL — pure data types shared by the editor, the
3
+ * Cloudflare Worker (handoff validation), and the skill's composer.
4
+ *
5
+ * No Fabric.js / runtime dependencies live here: `fabricData` is an opaque JSON
6
+ * object that the editor enlivens into a Fabric object at load time. Keeping this
7
+ * module dependency-free is what lets the Worker and the standalone skill bundle
8
+ * import + validate the same DSL without pulling in the editor.
9
+ */
10
+
11
+ export type LayerType = 'background' | 'text' | 'image' | 'device' | 'shape';
12
+
13
+ export type TemplateRole = 'placeholder' | 'editable' | 'fixed';
14
+
15
+ /** Current ScreenLayersJSON schema version. */
16
+ export const CURRENT_SCHEMA_VERSION = 2;
17
+
18
+ /** JSON representation of a single layer for persistence + handoff. */
19
+ export interface LayerJSON {
20
+ id: string;
21
+ name: string;
22
+ type: LayerType;
23
+ visible: boolean;
24
+ locked: boolean;
25
+ /** Template-related metadata (optional). */
26
+ templateRole?: TemplateRole;
27
+ templateKey?: string;
28
+ /** Serialized Fabric.js object data (null for the background layer). */
29
+ fabricData: object | null;
30
+ }
31
+
32
+ export interface ColorStop {
33
+ offset: number;
34
+ color: string;
35
+ }
36
+
37
+ /** Background is stored separately from layers (it isn't a Fabric object). */
38
+ export interface BackgroundJSON {
39
+ type: 'solid' | 'gradient';
40
+ color?: string;
41
+ gradient?: {
42
+ type: 'linear' | 'radial';
43
+ colorStops: ColorStop[];
44
+ /** @deprecated v1 only — use colorStops. Kept for backward compatibility. */
45
+ colors?: string[];
46
+ angle?: number;
47
+ coords?: { x1: number; y1: number; x2: number; y2: number };
48
+ };
49
+ }
50
+
51
+ /** One screen (canvas) of a design. */
52
+ export interface ScreenLayersJSON {
53
+ schemaVersion: number;
54
+ canvasWidth?: number;
55
+ canvasHeight?: number;
56
+ layers: LayerJSON[];
57
+ background?: BackgroundJSON;
58
+ }
59
+
60
+ /** A reusable, multi-screen design — the unit the skill produces + hands off. */
61
+ export interface Template {
62
+ id: string;
63
+ name: string;
64
+ description?: string;
65
+ thumbnail: string;
66
+ tags: string[];
67
+ version: string;
68
+ screens: ScreenLayersJSON[];
69
+ createdAt?: string;
70
+ author?: string;
71
+ }
@@ -0,0 +1,125 @@
1
+ import type { LayerJSON, ScreenLayersJSON, Template } from './types';
2
+ import { CURRENT_SCHEMA_VERSION } from './types';
3
+
4
+ const LAYER_TYPES = ['background', 'text', 'image', 'device', 'shape'];
5
+
6
+ /** Check if a value is a valid LayerJSON. */
7
+ export function isValidLayerJSON(data: unknown): data is LayerJSON {
8
+ if (!data || typeof data !== 'object') return false;
9
+ const obj = data as Record<string, unknown>;
10
+ return (
11
+ typeof obj.id === 'string' &&
12
+ typeof obj.name === 'string' &&
13
+ typeof obj.type === 'string' &&
14
+ LAYER_TYPES.includes(obj.type as string) &&
15
+ typeof obj.visible === 'boolean' &&
16
+ typeof obj.locked === 'boolean'
17
+ );
18
+ }
19
+
20
+ /** Check if a value is a valid ScreenLayersJSON. */
21
+ export function isValidScreenLayersJSON(data: unknown): data is ScreenLayersJSON {
22
+ if (!data || typeof data !== 'object') return false;
23
+ const obj = data as Record<string, unknown>;
24
+ return (
25
+ typeof obj.schemaVersion === 'number' &&
26
+ Array.isArray(obj.layers) &&
27
+ obj.layers.every(isValidLayerJSON)
28
+ );
29
+ }
30
+
31
+ /** Check if a value is a valid Template. */
32
+ export function isValidTemplate(data: unknown): data is Template {
33
+ if (!data || typeof data !== 'object') return false;
34
+ const obj = data as Record<string, unknown>;
35
+ return (
36
+ typeof obj.id === 'string' &&
37
+ typeof obj.name === 'string' &&
38
+ typeof obj.thumbnail === 'string' &&
39
+ Array.isArray(obj.tags) &&
40
+ typeof obj.version === 'string' &&
41
+ Array.isArray(obj.screens) &&
42
+ obj.screens.every(isValidScreenLayersJSON)
43
+ );
44
+ }
45
+
46
+ /** Migrate a ScreenLayersJSON from older schema versions to the current one. */
47
+ export function migrateScreenLayersJSON(json: ScreenLayersJSON): ScreenLayersJSON {
48
+ let migrated = { ...json };
49
+
50
+ // v1 -> v2: convert gradient `colors` array to `colorStops` with offsets.
51
+ if (migrated.schemaVersion < 2) {
52
+ const gradient = migrated.background?.gradient;
53
+ const hasColors = gradient?.colors && gradient.colors.length > 0;
54
+ const hasColorStops = gradient?.colorStops && gradient.colorStops.length > 0;
55
+
56
+ if (hasColors && !hasColorStops) {
57
+ const colors = gradient!.colors!;
58
+ migrated = {
59
+ ...migrated,
60
+ schemaVersion: 2,
61
+ background: {
62
+ ...migrated.background!,
63
+ gradient: {
64
+ ...gradient!,
65
+ colorStops: colors.map((color, index) => ({
66
+ offset: colors.length > 1 ? index / (colors.length - 1) : 0,
67
+ color
68
+ }))
69
+ }
70
+ }
71
+ };
72
+ delete migrated.background!.gradient!.colors;
73
+ } else {
74
+ migrated.schemaVersion = 2;
75
+ }
76
+ }
77
+
78
+ return migrated;
79
+ }
80
+
81
+ /** Generate a unique layer id. */
82
+ export function generateLayerId(): string {
83
+ return `layer-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
84
+ }
85
+
86
+ export interface ValidationResult {
87
+ valid: boolean;
88
+ errors: string[];
89
+ }
90
+
91
+ /**
92
+ * Strict, error-reporting validation for a Template (used by the Worker on
93
+ * handoff upload and by the skill's composer before sending). Unlike the boolean
94
+ * `isValidTemplate`, this returns a list of human-readable problems.
95
+ */
96
+ export function validateTemplate(data: unknown): ValidationResult {
97
+ const errors: string[] = [];
98
+ if (!data || typeof data !== 'object') {
99
+ return { valid: false, errors: ['Template must be an object'] };
100
+ }
101
+ const t = data as Record<string, unknown>;
102
+
103
+ if (typeof t.id !== 'string' || !t.id) errors.push('id must be a non-empty string');
104
+ if (typeof t.name !== 'string' || !t.name) errors.push('name must be a non-empty string');
105
+
106
+ if (!Array.isArray(t.screens) || t.screens.length === 0) {
107
+ errors.push('screens must be a non-empty array');
108
+ } else {
109
+ t.screens.forEach((screen, i) => {
110
+ if (!isValidScreenLayersJSON(screen)) {
111
+ errors.push(`screens[${i}] is not a valid screen`);
112
+ return;
113
+ }
114
+ const s = screen as ScreenLayersJSON;
115
+ if (s.schemaVersion > CURRENT_SCHEMA_VERSION) {
116
+ errors.push(`screens[${i}] has unsupported schemaVersion ${s.schemaVersion}`);
117
+ }
118
+ s.layers.forEach((layer, j) => {
119
+ if (!isValidLayerJSON(layer)) errors.push(`screens[${i}].layers[${j}] is invalid`);
120
+ });
121
+ });
122
+ }
123
+
124
+ return { valid: errors.length === 0, errors };
125
+ }