@appshoteditor/shot-dsl 0.1.2 → 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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/src/compose.ts +183 -20
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.1.2",
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/compose.ts CHANGED
@@ -1,4 +1,4 @@
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
4
  import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
@@ -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
- * Deterministically assemble a Template from a plan. Each screen gets a background,
43
- * a device-framed screenshot sitting in the lower ~60%, and a headline across the
44
- * top (marked editable so the user can tweak it in the editor). Claude decides the
45
- * plan (which benefit, copy, device, palette); this turns it into valid DSL.
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,45 +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: canvasWidth, height: canvasHeight } = explicit
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
- centerY: canvasHeight * 0.6 // sit the device lower, leaving room for the headline
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. Center the
68
- // headline horizontally and sit it near the top, leaving room for the device.
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: canvasWidth / 2,
72
- top: canvasHeight * 0.12,
73
- width: canvasWidth * 0.84,
74
- fontSize: 26,
210
+ left: W / 2,
211
+ top: blockTop + block.headlineHeight / 2,
212
+ width: textWidth,
213
+ fontSize: block.headlineSize,
75
214
  fontWeight: '800',
76
- fill: screen.headlineColor ?? '#ffffff',
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
- // z-order: screenshot (bottom) -> frame -> headline (top)
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,
249
+ canvasWidth: W,
250
+ canvasHeight: H,
88
251
  // Tag the device GROUP (multi-device) so a mixed plan lands as separate sidebar groups in
89
252
  // the editor instead of relying on frame inference. Absent ⇒ editor infers it.
90
253
  deviceClass: deviceClassForDeviceId(screen.deviceId) ?? undefined,
91
- layers: [screenshot, frame, headline]
254
+ layers
92
255
  });
93
256
  });
94
257