@appshoteditor/shot-dsl 0.2.0 → 0.4.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 CHANGED
@@ -10,15 +10,21 @@ composer — so a layout composed by the skill renders identically in the editor
10
10
  ## What's inside
11
11
 
12
12
  - **Types** — `Template`, `ScreenLayersJSON`, `LayerJSON`, `BackgroundJSON`, `CURRENT_SCHEMA_VERSION`.
13
- - **Validators** — `validateTemplate()` (strict, error-reporting), `isValidTemplate`, `migrateScreenLayersJSON`.
13
+ - **Validators** — `validateTemplate()` (strict, error-reporting), `isValidTemplate`, `migrateScreenLayersJSON`,
14
+ `validateDeviceScreenshot`, `isUploadedScreenshotSrc`.
14
15
  - **Builders** — `makeTextLayer`, `makeImageLayer`, `makeShapeLayer`, `makeSolid/GradientBackground`,
15
16
  `makeScreen`, `makeTemplate`.
16
- - **Device geometry** — `deviceFrames`, `getDeviceFrame`, `makeDeviceFrameLayers`, `calculateDeviceScale`.
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.
17
+ - **Device geometry** — `deviceFrames`, `getDeviceFrame`, `makeDeviceFrameLayer`, `calculateDeviceScale`,
18
+ `computeScreenshotPlacement` (the screenshot-in-frame fit/clip math the editor uses).
19
+ - **Composer** — `composeTemplate(plan)`: a benefit/screenshot plan → a validated, device-framed `Template`
20
+ (`composeSet(plan)` returns the same template plus a `report` with lint warnings and per-screen
21
+ geometry). Per screen: `headline`, optional `subheadline` / `headlineColor` / `subheadlineColor`, and
22
+ `layout` (`text-top` default, `text-bottom`, `device-bleed` — see `COMPOSE_LAYOUTS`). All geometry
23
+ (device scale/position, font sizes, padding) is derived from the canvas size, so a plan composes to
24
+ the same proportions at 280×608 editor units or 1320×2868 native pixels.
25
+ - **Set layout system (0.4.0)** — `solveVertical` / `NO_TANGENT` (the no-tangent rule), `focusReach`,
26
+ `rotatedBox` (`layout-system.ts`); `makeVariants(plan)` (three concept plans, `variants.ts`); small
27
+ colour helpers (`color.ts`).
22
28
 
23
29
  ```ts
24
30
  import { composeTemplate, validateTemplate } from '@appshoteditor/shot-dsl';
@@ -39,6 +45,199 @@ const template = composeTemplate({
39
45
  validateTemplate(template); // { valid: true, errors: [] }
40
46
  ```
41
47
 
48
+ ## Set layout system (0.4.0)
49
+
50
+ `composeSet` lays a plan out as ONE system, the way a screenshot designer would:
51
+
52
+ 1. **One system per set.** It measures every screen's text block first and reserves a text area
53
+ for the TALLEST one (per canvas size). The headline size is fixed for the whole set
54
+ (`HEADLINE_SIZE` × the type unit). Long copy is never shrunk: it gets a lint warning instead.
55
+ Screens that share a canvas size, presentation, device, layout and tilt (`report.screens[i].group`)
56
+ get the same subject scale and baseline, so copy length never moves or resizes anything.
57
+ 2. **No tangents.** A device (or frameless screenshot) either clears the far edge by ≥ 4% of H or
58
+ bleeds off it by ≥ 12% of its own height. The band in between is the "just touching" look, and
59
+ `solveVertical` never emits it. `style.bleed` picks how a tangent is resolved:
60
+ - `auto` (default): a device that already clears by the margin stays as it is. Otherwise it
61
+ bleeds decisively: it grows toward the 90%-of-W cap, then shifts down. It clears with the
62
+ margin only when every ≥ 12% bleed would crop its focus band.
63
+ - `none`: always fully visible.
64
+ - `deep`: bleed ≥ 25%.
65
+ 3. **No side tangents.** No subject is wider than 90% of W (`NO_TANGENT.maxWidth`). The part that is
66
+ actually on the canvas keeps ≥ 5% of W from both side edges. The only side bleed is a deliberate
67
+ panorama straddle across a seam.
68
+ 4. **Focus-aware, on all four edges.** Marked `focus` band corners (`{ top, bottom }`, fractions of
69
+ the screenshot height) always stay:
70
+ - ≥ 2% of H inside the top and bottom edges;
71
+ - ≥ 2% of W inside the side edges and any seam. This includes tilted subjects: their rotated
72
+ corners are checked.
73
+
74
+ A bleed that would crop the band is reduced, or turned into the clear-with-margin option. When a
75
+ tilt doesn't fit the side rules, the composer first shrinks the device, or the zoom card (keeping
76
+ its aspect), by at most 20%. After
77
+ that it reduces the tilt in quarter steps, down to straight if needed, and warns `tilt-reduced`.
78
+ In a group, the most restrictive focus decides for every screen. A zoom card that can't show a
79
+ whole band warns `zoom-focus-cropped`.
80
+ 5. **Room for the device.** The device must keep at least half the size it would get on this canvas
81
+ with NO copy. That size is height-limited for tall devices on landscape canvases, such as an iMac
82
+ on 608×380. If the text leaves less room, `composeSet` throws "copy too long for this canvas … cut
83
+ the subheadline or headline". If even the no-copy fit is under 30% of the target width, it throws
84
+ "device … doesn't fit a W×H canvas … use the device's own canvas", because the canvas, not the
85
+ copy, is the problem.
86
+
87
+ Text metrics are per font (`FONT_CHAR_WIDTH`, plus character classes: capitals and m/w count
88
+ wider, i/l and punctuation narrower). They are calibrated to be conservative for every
89
+ `COMPOSE_FONTS` entry, so an estimated 2-line headline never renders as 3.
90
+
91
+ Plan additions (all optional; a 0.3.0 plan composes and validates unchanged):
92
+
93
+ | Field | Where | Meaning |
94
+ | --- | --- | --- |
95
+ | `style.presentation` | plan | `device` (default) · `frameless` (rounded screenshot + shadow) · `zoom` (magnified crop of `crop`, else the centre of the `focus` band) |
96
+ | `style.tilt`, `style.tiltScreens` | plan | Degrees (clockwise) applied only to the listed screen indices. A tilted subject prefers a bleed under `auto`/`deep`; the tilt is reduced (`tilt-reduced`) if it can't keep the side margins. |
97
+ | `style.bleed` | plan | `auto` · `none` · `deep` (see above) |
98
+ | `style.palette` | plan | `{ mode: "family" \| "sequence", colors: [hex…] }`. Background for screens that omit one. |
99
+ | `style.panorama` | plan | `{ spans: [[0,1], …], straddle?: true \| [screen…], decoration?: "orbs" \| "none" }`. Adjacent screens share one continuous background; `straddle` picks the span-start screens whose device crosses the seam. |
100
+ | `style.font` | plan | One of `COMPOSE_FONTS` (the editor's font list). |
101
+ | `focus`, `crop`, `presentation`, `tilt`, `badge` | screen | Focus band · zoom crop `{x,y,w,h}` · per-screen overrides · a social-proof pill above the headline |
102
+
103
+ Output shapes:
104
+
105
+ - **Headline colour.** An explicit `headlineColor` / `subheadlineColor` always wins. Otherwise, on a
106
+ palette background or in a panorama span, it is white or `#111827`, whichever has the better
107
+ worst-case WCAG contrast. The contrast is checked against the background actually behind the text
108
+ block, sampled over it. For a span, that is the span's N·W gradient, which uses the FIRST
109
+ screen's background, at this screen's offset. A screen with its own explicit background outside a
110
+ span keeps the white default.
111
+ - **Frameless / zoom** is a plain `type: 'image'` layer:
112
+ - Its `src` is the uploaded screenshot, with `crossOrigin: 'anonymous'`.
113
+ - It has a rounded `clipPath` Rect in the image's local space, `imageCornerRadius` (canvas units,
114
+ the editor's corner-radius control) and a Fabric `shadow` (blur/offset in the object's own units,
115
+ like the editor's shadow control).
116
+ - Zoom adds Fabric `cropX`/`cropY` with `width`/`height` set to the crop size.
117
+ - Every screen of a zoom group uses the same card box.
118
+ - **Tilt** is the device layer's `angle`. The editor rotates the screenshot with the frame on attach, on
119
+ move/scale/rotate, and in the offscreen export.
120
+ - **Panorama** is pure DSL:
121
+ - Each screen of a span gets a locked `Rect` of size N·W × H at `left = N·W/2 − k·W`, painted with
122
+ the span-start screen's background.
123
+ - A solid colour stays solid.
124
+ - A linear gradient keeps its stops and `angle`, applied across N·W; without an `angle` it runs
125
+ corner to corner.
126
+ - Radial gradients and explicit `coords` throw a clear error.
127
+ - Optional seam orbs are moved or shrunk so they never overlap either neighbour's text block, and
128
+ dropped when there's no room.
129
+ - With `straddle` (`true` = every span, or a list of span-start screen indices), that span's first
130
+ device is shifted right so it crosses the seam. More than one crossing per set is lint-warned
131
+ (`panorama-straddle-count`). A copy of it,
132
+ named "… (continued)", is emitted on the next screen at `left − W`, below that screen's own device.
133
+ - The straddle is only ever a rightward shift. It must keep the left side margin, and when a
134
+ `focus` band is marked, the band stays ≥ 2% of W inside the seam. Otherwise the straddle is
135
+ skipped (`straddle-skipped`) and the device is placed like any other screen.
136
+ - In the editor, the halves are independent layers: moving one does not move the other.
137
+ - **Badge** is a rounded `Rect` pill plus a `Textbox` (`templateKey: 'badge'`). They sit in a badge row
138
+ above the headline, reserved set-wide, so they never collide with the text or the device.
139
+
140
+ `report.warnings` (`ComposeWarning`) flags:
141
+
142
+ - a headline over 5 words, or estimated at more than 2 lines at the set size;
143
+ - a subheadline estimated at more than 1 line;
144
+ - more than 2 tilted screens;
145
+ - a seam crossing a headline or focus band, or a straddle that can't be checked because no focus
146
+ band is marked;
147
+ - skipped straddles, and more than one straddle per set;
148
+ - tilts reduced to keep the side margins;
149
+ - zoom crops that can't show their whole focus band;
150
+ - long badges.
151
+
152
+ Warnings never block composing.
153
+
154
+ `makeVariants(plan)` returns three distinct concepts, intended as Product Page Optimization test
155
+ candidates. Each is named `<name> — A Framed` / `— B Frameless` / `— C Panorama`:
156
+
157
+ - **A:** device frames, straight, all `text-top`.
158
+ - **B:** frameless. Screens with a `crop`, or a `focus` band ≤ 45% tall, become zoom cards.
159
+ - **C:** panorama spans: the input's `panorama.spans`, or adjacent pairs, with a continuous
160
+ background and seam orbs. Only screen 1 (the hero) is tilted (the input's `style.tilt`, or 8°)
161
+ and straddles its seam, unless the input sets `straddle`. Every other device stays centred and
162
+ straight.
163
+
164
+ **Kept from the input** in every concept:
165
+ - copy (headline / subheadline / badge) and `headlineColor` / `subheadlineColor`;
166
+ - `background`, `deviceId`, `screenshot`, `focus`, `crop`;
167
+ - canvas size;
168
+ - `style.palette`, `style.font` and `style.bleed` (default `auto`).
169
+
170
+ **Overridden:**
171
+ - every `layout` becomes `text-top`: screens only share one device scale and baseline when they
172
+ share a layout;
173
+ - per-screen `presentation` and `tilt` are dropped;
174
+ - A and B are straight, with no panorama.
175
+
176
+ ## Layer order
177
+
178
+ A screen's `layers` array is **bottom → top**: index 0 is painted first (canvas add order), the last
179
+ entry is frontmost. A composed screen is `[device, headline, subheadline?]`; the editor's built-in
180
+ templates list background shapes, then the device, then text. (The editor's layers *panel* shows the
181
+ reverse — top first.)
182
+
183
+ ## Device screenshots (0.3.0)
184
+
185
+ A device mockup is **one** layer. Its screenshot is a property of the device layer, at
186
+ `fabricData.screenshot`:
187
+
188
+ ```json
189
+ {
190
+ "id": "device-frame-layer-1790080000000-abc123def",
191
+ "name": "iPhone 17 Pro Max",
192
+ "type": "device",
193
+ "visible": true,
194
+ "locked": false,
195
+ "fabricData": {
196
+ "type": "image",
197
+ "src": "/devices/iphone-17-pro-max.webp",
198
+ "left": 140, "top": 438.69, "width": 1520, "height": 3068,
199
+ "scaleX": 0.175, "scaleY": 0.175, "originX": "center", "originY": "center",
200
+ "layerId": "device-frame-layer-1790080000000-abc123def",
201
+ "layerType": "deviceFrame",
202
+ "layerRole": "frame",
203
+ "deviceFrameId": "device-frame-layer-1790080000000-abc123def",
204
+ "deviceId": "iphone_17_pro_max",
205
+ "deviceScale": 0.175,
206
+ "screenshot": { "src": "/api/screenshots/<asset-id>/raw", "width": 1320, "height": 2868 }
207
+ }
208
+ }
209
+ ```
210
+
211
+ `screenshot` is `{ src, width, height, rotation? }` (`DeviceScreenshotJSON`; `rotation` ∈ 0/90/180/270).
212
+ Producers emit **no** screenshot geometry or clip path: the editor places the image under the frame
213
+ and clips it to the screen with the same code path it uses when a user drops a screenshot onto a
214
+ frame (`computeScreenshotPlacement`), so the screenshot belongs to the frame (it is not a separate
215
+ layer). `validateTemplate` requires `src` to be an uploaded-asset URL (`/api/screenshots/<id>/raw`) —
216
+ external, protocol-relative and `data:` URLs are rejected. It also requires every device layer to have a
217
+ known `deviceId` and a frame-asset `src` (`/devices/<name>.webp|png`), and rejects duplicate layer ids
218
+ or `deviceFrameId`s within a screen. Every other image reference in a handoff (image layers, pattern
219
+ `fill.source`, nested objects) must be an uploaded screenshot or a `/devices/` asset. Frame metadata
220
+ (`layerRole: 'frame'`, `deviceFrameId`, `deviceId`, `deviceScale`) is only allowed on device layers,
221
+ `layerRole: 'screenshot'` only on legacy image layers, and editor-internal props
222
+ (`EDITOR_INTERNAL_PROPS`, e.g. `pendingScreenshot`) are rejected everywhere, as are `__proto__` /
223
+ `constructor` / `prototype` keys at any depth (`FORBIDDEN_KEYS`).
224
+
225
+ `computeScreenshotPlacement` centres the screenshot on the frame's screen bounds; the screen-centre
226
+ offset scales with the frame's scale *relative to* `deviceScale` (0.2.x editors multiplied by the
227
+ absolute frame scale, misplacing off-centre screens such as MacBooks by a few canvas units).
228
+
229
+ **Legacy (≤ 0.2.0):** screenshots were a separate `type: 'image'` layer with
230
+ `fabricData.layerRole: 'screenshot'` and a `deviceFrameId` matching its frame, placed directly below
231
+ the frame. `validateTemplate` still accepts that shape (its `src` must also be an uploaded-asset URL)
232
+ and the editor converts it on import.
233
+ `makeDeviceFrameLayers()` (which returned `{ screenshot, frame }`) was replaced by
234
+ `makeDeviceFrameLayer()` (returns the single device layer).
235
+
236
+ > Consumers: a template in the 0.3.0 shape needs an editor that understands `fabricData.screenshot`
237
+ > (appshoteditor.com with the matching importer). 0.4.0 adds no new layer shapes that the 0.3.0 validator
238
+ > or importer would reject: frameless/zoom are ordinary image layers, and panorama and badges are
239
+ > ordinary shape and text layers.
240
+
42
241
  Zero runtime dependencies. The `schemaVersion` is the compatibility contract between producers and the editor.
43
242
 
44
243
  > Intended for use via a bundler (Vite, esbuild, etc.). Device `frameAsset` values are URL paths the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appshoteditor/shot-dsl",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
@@ -20,7 +20,10 @@
20
20
  "src/builders.ts",
21
21
  "src/compose.ts",
22
22
  "src/frames.ts",
23
- "src/device-frames.ts"
23
+ "src/device-frames.ts",
24
+ "src/layout-system.ts",
25
+ "src/color.ts",
26
+ "src/variants.ts"
24
27
  ],
25
28
  "sideEffects": false,
26
29
  "keywords": [
package/src/color.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tiny, dependency-free colour helpers for the composer's palette / panorama / badge styling.
3
+ * Only `#rgb` / `#rrggbb` hex input is understood; anything else is passed through untouched
4
+ * (callers fall back to sensible defaults).
5
+ */
6
+
7
+ export type RGB = { r: number; g: number; b: number };
8
+
9
+ const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
10
+
11
+ export function isHexColor(value: unknown): value is string {
12
+ return typeof value === 'string' && HEX.test(value);
13
+ }
14
+
15
+ export function parseHex(hex: string): RGB | null {
16
+ if (!isHexColor(hex)) return null;
17
+ let h = hex.slice(1);
18
+ if (h.length === 3) h = h.replace(/./g, (c) => c + c);
19
+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
20
+ }
21
+
22
+ export function toHex({ r, g, b }: RGB): string {
23
+ const c = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
24
+ return `#${c(r)}${c(g)}${c(b)}`.toUpperCase();
25
+ }
26
+
27
+ /** Linear mix of `a` toward `b` by `t` (0 → a, 1 → b). Non-hex input returns `a`. */
28
+ export function mixHex(a: string, b: string, t: number): string {
29
+ const x = parseHex(a);
30
+ const y = parseHex(b);
31
+ if (!x || !y) return a;
32
+ return toHex({ r: x.r + (y.r - x.r) * t, g: x.g + (y.g - x.g) * t, b: x.b + (y.b - x.b) * t });
33
+ }
34
+
35
+ export const lighten = (hex: string, t: number): string => mixHex(hex, '#ffffff', t);
36
+ export const darken = (hex: string, t: number): string => mixHex(hex, '#000000', t);
37
+
38
+ /** WCAG relative luminance (0 = black, 1 = white); 0 for non-hex input. */
39
+ export function luminance(hex: string): number {
40
+ const c = parseHex(hex);
41
+ if (!c) return 0;
42
+ const lin = (v: number) => {
43
+ const s = v / 255;
44
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
45
+ };
46
+ return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
47
+ }
48
+
49
+ /** `rgba(r,g,b,a)` from a hex colour (white for non-hex input). */
50
+ export function rgba(hex: string, alpha: number): string {
51
+ const c = parseHex(hex) ?? { r: 255, g: 255, b: 255 };
52
+ return `rgba(${c.r},${c.g},${c.b},${alpha})`;
53
+ }
54
+
55
+ /** WCAG contrast ratio between two hex colours (1 … 21). */
56
+ export function contrastRatio(a: string, b: string): number {
57
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
58
+ return (hi + 0.05) / (lo + 0.05);
59
+ }
60
+
61
+ export const DARK_TEXT = '#111827';
62
+ export const LIGHT_TEXT = '#FFFFFF';
63
+
64
+ /**
65
+ * The text colour (white or near-black) with the best WORST-case contrast against every sampled
66
+ * background colour (e.g. several points of a gradient behind a headline).
67
+ */
68
+ export function readableTextOn(backgrounds: string[]): string {
69
+ const worst = (text: string) => Math.min(...backgrounds.map((bg) => contrastRatio(text, bg)));
70
+ return worst(DARK_TEXT) > worst(LIGHT_TEXT) ? DARK_TEXT : LIGHT_TEXT;
71
+ }
72
+
73
+ /** Readable text colour on a background of `hex` (see readableTextOn). */
74
+ export function contrastText(hex: string): string {
75
+ return readableTextOn([hex]);
76
+ }