@appshoteditor/shot-dsl 0.5.0 → 0.5.1

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
@@ -174,11 +174,14 @@ Output shapes:
174
174
  - **Callouts** (`screen.callout` crop, or `style.callouts: "auto"` from a focus band ≤ 50% tall): an
175
175
  image layer with the SAME uploaded `src` + `cropX`/`cropY`, rounded, shadowed, magnified 1.6–2.2×
176
176
  (down to 1.25× to fit), inside 3.5% side margins, between the text block and a 4% far-edge margin.
177
- It may cover its own source slice (the pop-out), but it hides at most `CALLOUT_MAX_FOCUS_COVER`
178
- (35%) of the REST of the focus band: it is centred on its source when that allows, else moved
179
- up/down or out to a side edge (breaking out of the device), else made smaller; if nothing works it
180
- is skipped (`callout-skipped`). `report.screens[i].callout.focusCover` is the share hidden. Never on
181
- zoom cards or a straddling device.
177
+ The requested crop is automatically padded, on whichever sides have room inside the screenshot,
178
+ before it's magnified, so the rounded corners of the card don't clip content sitting at the
179
+ requested crop's own corners (e.g. a selection handle); a side with no room (flush against the
180
+ screenshot edge) can still clip there. It may cover its own source slice (the pop-out), but
181
+ it hides at most `CALLOUT_MAX_FOCUS_COVER` (35%) of the REST of the focus band: it is centred on its
182
+ source when that allows, else moved up/down or out to a side edge (breaking out of the device), else
183
+ made smaller; if nothing works it is skipped (`callout-skipped`). `report.screens[i].callout.focusCover`
184
+ is the share hidden. Never on zoom cards or a straddling device.
182
185
  - **Mascots** (`plan.art` + `screen.mascot` / `style.hero.mascot`): an image layer (uploaded src,
183
186
  `flipX` to face into the canvas when `faces` is given, shadow). Anchors `headline` / `device-top` /
184
187
  `device-side` / `seam` are tried in order with fallbacks at 100/85/70% size; a placement must clear
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@appshoteditor/shot-dsl",
3
- "version": "0.5.0",
4
- "description": "App Shot Editor layout DSL + device-frame geometry \u2014 framework-free building blocks for composing editable App Store screenshot layouts. Intended for use via a bundler (Vite, esbuild, etc.).",
3
+ "version": "0.5.1",
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",
7
7
  "main": "./src/index.ts",
package/src/compose.ts CHANGED
@@ -397,7 +397,8 @@ const BADGE_MAX_CHARS = 28;
397
397
 
398
398
  // Frameless / zoom styling.
399
399
  const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel → a touch narrower)
400
- const FRAMELESS_RADIUS = 0.1; // × rendered width
400
+ const FRAMELESS_RADIUS = 0.1; // × rendered width — fallback, and the cap for `frameCornerRadiusRatio`
401
+ const FRAMELESS_RADIUS_MIN = 0.03; // × rendered width — floor for a device with squarer screen corners (e.g. iPad)
401
402
  const ZOOM_WIDTH = 0.88; // × W
402
403
  const ZOOM_RADIUS = 0.05; // × W
403
404
  const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
@@ -419,6 +420,15 @@ export const CALLOUT_MAG = { min: 1.6, max: 2.2, floor: 1.25 } as const;
419
420
  */
420
421
  export const CALLOUT_MAX_FOCUS_COVER = 0.35;
421
422
  const CALLOUT_RADIUS = 0.035; // × W
423
+ /**
424
+ * Padding added around the requested callout crop (screenshot px), as a multiple of the rounded
425
+ * mask's LOCAL corner radius (`CALLOUT_RADIUS * W`, converted to the crop's own pixel space — see
426
+ * `calloutGeometry`), so the rounded corners of the clip mask (`makeCalloutLayer`) don't cut into
427
+ * content at the requested crop's own corners (e.g. selection handles). The geometric minimum to
428
+ * keep a corner point inside a rounded corner of radius `rx` is `1 - 1/√2 ≈ 0.293`; the extra margin
429
+ * covers the fact that padding the crop shrinks the eventual magnification a little further.
430
+ */
431
+ const CALLOUT_CORNER_PAD = 0.35;
422
432
  // Auto crop: fractions of the screenshot width (left-aligned — UI rows start at the left, so a cut
423
433
  // at the right loses a chevron, not the first letters of a label); card h ÷ w.
424
434
  const CALLOUT_AUTO = { x: 0.02, w: 0.64, aspect: 0.4 };
@@ -1041,6 +1051,19 @@ function shadowFor(W: number, scale: number, look: Look, spec: { blur: number; o
1041
1051
  return { color: look.shadowColor, blur: (spec.blur * W) / scale, offsetX: 0, offsetY: (spec.offsetY * W) / scale };
1042
1052
  }
1043
1053
 
1054
+ /**
1055
+ * Non-zoom frameless corner radius, as a ratio of the rendered width: the device's own screen corner
1056
+ * radius (relative to its screen width), clamped to [FRAMELESS_RADIUS_MIN, FRAMELESS_RADIUS]. A flat
1057
+ * FRAMELESS_RADIUS reads fine on an iPhone (its screen corners are nearly that round) but is far too
1058
+ * round on an iPad (squarer corners, wide screen) — round enough to eat into the status bar. Falls
1059
+ * back to FRAMELESS_RADIUS when the device is unknown.
1060
+ */
1061
+ function frameCornerRadiusRatio(deviceId: string): number {
1062
+ const device = getDeviceFrame(deviceId);
1063
+ if (!device) return FRAMELESS_RADIUS;
1064
+ return Math.min(FRAMELESS_RADIUS, Math.max(FRAMELESS_RADIUS_MIN, device.cornerRadius / device.screenBounds.width));
1065
+ }
1066
+
1044
1067
  /** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
1045
1068
  function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look): LayerJSON {
1046
1069
  const id = generateLayerId();
@@ -1048,7 +1071,7 @@ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look):
1048
1071
  const zoom = p.zoom;
1049
1072
  const width = zoom ? zoom.cropW : shot.width;
1050
1073
  const height = zoom ? zoom.cropH : shot.height;
1051
- const radius = zoom ? ZOOM_RADIUS * r.W : FRAMELESS_RADIUS * width * p.scale;
1074
+ const radius = zoom ? ZOOM_RADIUS * r.W : frameCornerRadiusRatio(r.plan.deviceId) * width * p.scale;
1052
1075
  const fabricData: Record<string, unknown> = {
1053
1076
  type: 'image',
1054
1077
  src: shot.url,
@@ -1578,7 +1601,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1578
1601
  rects: TextRects;
1579
1602
  text: string;
1580
1603
  subText?: string;
1581
- callout?: { rect: Rect; mag: number; crop: { x: number; y: number; w: number; h: number }; scale: number; focusCover: number };
1604
+ callout?: { rect: Rect; mag: number; crop: { x: number; y: number; w: number; h: number }; requested: { x: number; y: number; w: number; h: number }; scale: number; focusCover: number };
1582
1605
  focusCore?: Rect;
1583
1606
  mascot?: { rect: Rect; scale: number; flip: boolean; art: ComposeArt; seamPartner?: number; dx?: number };
1584
1607
  }
@@ -2101,21 +2124,49 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
2101
2124
  const req = r.calloutReq!;
2102
2125
  const { W, H } = r;
2103
2126
  const shot = r.plan.screenshot;
2104
- const crop = { x: req.x * shot.width, y: req.y * shot.height, w: req.w * shot.width, h: req.h * shot.height };
2127
+ const requested = { x: req.x * shot.width, y: req.y * shot.height, w: req.w * shot.width, h: req.h * shot.height };
2105
2128
  const scr = p.subject.screen;
2106
2129
  // Canvas px per screenshot px as the editor fits it into the screen bounds (frameless: the image).
2107
2130
  const screenScale = (p.scale * scr.width) / shot.width;
2108
- const centre = subjectPointToCanvas(
2109
- p.subject,
2110
- { x: scr.x + ((crop.x + crop.w / 2) / shot.width) * scr.width, y: scr.y + ((crop.y + crop.h / 2) / shot.height) * scr.height },
2111
- { cx, cy: p.cy, scale: p.scale, angle: p.angle }
2112
- );
2131
+ // Vertical band the card must fit in — computed before padding since it only depends on the text
2132
+ // layout, not the crop, and the padding estimate below needs it too (the same vertical fit cap
2133
+ // applies to both).
2113
2134
  const texts = textRectList(rects);
2114
2135
  const gap = CALLOUT_TEXT_GAP * H;
2115
2136
  const [lo, hi] =
2116
2137
  r.layout === 'text-bottom'
2117
2138
  ? [CALLOUT_EDGE * H, Math.min(...texts.map((t) => t.top)) - gap]
2118
2139
  : [Math.max(...texts.map((t) => t.bottom)) + gap, H * (1 - CALLOUT_EDGE)];
2140
+ // Pad the requested crop on sides that have room inside the screenshot, so its own corners land
2141
+ // inside the rounded clip mask (`makeCalloutLayer`) instead of being cut off there. `pad` is sized
2142
+ // from a PRELIMINARY magnification: the same formula AND the same fit caps (side margins, vertical
2143
+ // room) used below, run on the unpadded crop — since the final mag depends on the padded crop's own
2144
+ // size, this is a deterministic chicken-and-egg break, not the exact final mag. The placement
2145
+ // search below can still shrink the final mag further (retrying smaller sizes when every position
2146
+ // hides too much of the focus band), so `makeCalloutLayer` also hard-caps the mask's own corner
2147
+ // radius as a backstop — the padding here just keeps that cap from binding in the common case.
2148
+ const baseWReq = requested.w * screenScale;
2149
+ const baseHReq = requested.h * screenScale;
2150
+ let prelimMag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseWReq));
2151
+ prelimMag = Math.min(prelimMag, ((1 - 2 * CALLOUT_MARGIN) * W) / baseWReq, (hi - lo) / baseHReq);
2152
+ // Guard against a degenerate (non-positive) estimate — e.g. no vertical room at all — blowing up
2153
+ // `pad`; the real computation below bails out (`callout-skipped`) in that same case anyway, so the
2154
+ // crop's exact shape no longer matters once that happens.
2155
+ prelimMag = Math.max(prelimMag, 1e-6);
2156
+ const pad = (CALLOUT_CORNER_PAD * CALLOUT_RADIUS * W) / (screenScale * prelimMag);
2157
+ const padX0 = Math.max(0, requested.x - pad);
2158
+ const padY0 = Math.max(0, requested.y - pad);
2159
+ const crop = {
2160
+ x: padX0,
2161
+ y: padY0,
2162
+ w: Math.min(shot.width, requested.x + requested.w + pad) - padX0,
2163
+ h: Math.min(shot.height, requested.y + requested.h + pad) - padY0
2164
+ };
2165
+ const centre = subjectPointToCanvas(
2166
+ p.subject,
2167
+ { x: scr.x + ((crop.x + crop.w / 2) / shot.width) * scr.width, y: scr.y + ((crop.y + crop.h / 2) / shot.height) * scr.height },
2168
+ { cx, cy: p.cy, scale: p.scale, angle: p.angle }
2169
+ );
2119
2170
  const baseW = crop.w * screenScale;
2120
2171
  const baseH = crop.h * screenScale;
2121
2172
  let mag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseW));
@@ -2165,7 +2216,7 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
2165
2216
  }
2166
2217
  }
2167
2218
  }
2168
- if (bestRect) return { rect: bestRect, mag: k, crop, scale: screenScale * k, focusCover: cover(bestRect) };
2219
+ if (bestRect) return { rect: bestRect, mag: k, crop, requested, scale: screenScale * k, focusCover: cover(bestRect) };
2169
2220
  if (k <= CALLOUT_MAG.floor) break;
2170
2221
  }
2171
2222
  warnings.push({
@@ -2178,7 +2229,24 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
2178
2229
 
2179
2230
  function makeCalloutLayer(r: ResolvedScreen, c: NonNullable<ReturnType<typeof calloutGeometry>>, look: Look): LayerJSON {
2180
2231
  const id = generateLayerId();
2181
- const radius = CALLOUT_RADIUS * r.W;
2232
+ // Hard guarantee, independent of how `calloutGeometry`'s padding estimate turned out (it's only a
2233
+ // preliminary-magnification guess — the placement search can shrink the final mag further): the
2234
+ // mask's corner radius can never clip the REQUESTED crop's own corners. On each side that had room
2235
+ // to pad (not clamped flush to the screenshot edge — a clamped side had no padding and was always
2236
+ // going to clip there, mask or no mask), the actual padding applied is the gap between `crop` and
2237
+ // `requested`. The geometric minimum margin to keep a corner inside a rounded corner of local
2238
+ // radius `rx` is `(1 − 1/√2) · rx`, so `rx` is capped at the smallest such padding ÷ (1 − 1/√2) —
2239
+ // converted back to a canvas-unit radius via `c.scale`. If every side is clamped (the crop already
2240
+ // spans the screenshot), there's no padding to guarantee anything from, so the normal radius stands.
2241
+ const shot = r.plan.screenshot;
2242
+ const EPS = 1e-6;
2243
+ const pads: number[] = [];
2244
+ if (c.crop.x > EPS) pads.push(c.requested.x - c.crop.x);
2245
+ if (c.crop.y > EPS) pads.push(c.requested.y - c.crop.y);
2246
+ if (c.crop.x + c.crop.w < shot.width - EPS) pads.push(c.crop.x + c.crop.w - (c.requested.x + c.requested.w));
2247
+ if (c.crop.y + c.crop.h < shot.height - EPS) pads.push(c.crop.y + c.crop.h - (c.requested.y + c.requested.h));
2248
+ const CORNER_INSET = 1 - 1 / Math.SQRT2; // ≈0.293 — geometric minimum margin to keep a corner inside a rounded corner of radius rx
2249
+ const radius = pads.length ? Math.min(CALLOUT_RADIUS * r.W, (Math.min(...pads) / CORNER_INSET) * c.scale) : CALLOUT_RADIUS * r.W;
2182
2250
  const fabricData: Record<string, unknown> = {
2183
2251
  type: 'image',
2184
2252
  src: r.plan.screenshot.url,