@appshoteditor/shot-dsl 0.5.0 → 0.5.2
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 +11 -6
- package/package.json +2 -2
- package/src/compose.ts +86 -11
- package/src/layout-system.ts +31 -10
package/README.md
CHANGED
|
@@ -64,7 +64,9 @@ validateTemplate(template); // { valid: true, errors: [] }
|
|
|
64
64
|
bleeds decisively: it grows toward the 90%-of-W cap, then shifts down. It clears with the
|
|
65
65
|
margin only when every ≥ 12% bleed would crop its focus band.
|
|
66
66
|
- `none`: always fully visible.
|
|
67
|
-
- `deep`: bleed
|
|
67
|
+
- `deep`: grows the subject toward a 25% bleed (still capped at 90% W). When growth alone can't
|
|
68
|
+
reach 25% (the size cap bites first), it shifts down too — but never further than a 12% bleed
|
|
69
|
+
needs, even though the target is 25%: it never shifts deeper just to force that target.
|
|
68
70
|
3. **No side tangents.** No subject is wider than 90% of W (`NO_TANGENT.maxWidth`). The part that is
|
|
69
71
|
actually on the canvas keeps ≥ 5% of W from both side edges. The only side bleed is a deliberate
|
|
70
72
|
panorama straddle across a seam.
|
|
@@ -174,11 +176,14 @@ Output shapes:
|
|
|
174
176
|
- **Callouts** (`screen.callout` crop, or `style.callouts: "auto"` from a focus band ≤ 50% tall): an
|
|
175
177
|
image layer with the SAME uploaded `src` + `cropX`/`cropY`, rounded, shadowed, magnified 1.6–2.2×
|
|
176
178
|
(down to 1.25× to fit), inside 3.5% side margins, between the text block and a 4% far-edge margin.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
The requested crop is automatically padded, on whichever sides have room inside the screenshot,
|
|
180
|
+
before it's magnified, so the rounded corners of the card don't clip content sitting at the
|
|
181
|
+
requested crop's own corners (e.g. a selection handle); a side with no room (flush against the
|
|
182
|
+
screenshot edge) can still clip there. It may cover its own source slice (the pop-out), but
|
|
183
|
+
it hides at most `CALLOUT_MAX_FOCUS_COVER` (35%) of the REST of the focus band: it is centred on its
|
|
184
|
+
source when that allows, else moved up/down or out to a side edge (breaking out of the device), else
|
|
185
|
+
made smaller; if nothing works it is skipped (`callout-skipped`). `report.screens[i].callout.focusCover`
|
|
186
|
+
is the share hidden. Never on zoom cards or a straddling device.
|
|
182
187
|
- **Mascots** (`plan.art` + `screen.mascot` / `style.hero.mascot`): an image layer (uploaded src,
|
|
183
188
|
`flipX` to face into the canvas when `faces` is given, shadow). Anchors `headline` / `device-top` /
|
|
184
189
|
`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.
|
|
4
|
-
"description": "App Shot Editor layout DSL + device-frame geometry
|
|
3
|
+
"version": "0.5.2",
|
|
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
|
@@ -295,6 +295,12 @@ export interface ComposeScreenMetrics {
|
|
|
295
295
|
headlineSize: number;
|
|
296
296
|
/** Headline box top ÷ H (identical across a set by construction). */
|
|
297
297
|
headlineTop: number;
|
|
298
|
+
/**
|
|
299
|
+
* Bottom of the reserved text block (headline + subheadline + badge row) ÷ H. For `text-bottom`
|
|
300
|
+
* layouts the text block sits near the canvas bottom (not the top), so `top − textBottom` (the
|
|
301
|
+
* subject's top minus this) isn't a meaningful "gap under the text" for that layout.
|
|
302
|
+
*/
|
|
303
|
+
textBottom: number;
|
|
298
304
|
tilt: number;
|
|
299
305
|
/** Horizontal centre ÷ W (≠ 0.5 for a straddling panorama device). */
|
|
300
306
|
centerX: number;
|
|
@@ -397,7 +403,8 @@ const BADGE_MAX_CHARS = 28;
|
|
|
397
403
|
|
|
398
404
|
// Frameless / zoom styling.
|
|
399
405
|
const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel → a touch narrower)
|
|
400
|
-
const FRAMELESS_RADIUS = 0.1; // × rendered width
|
|
406
|
+
const FRAMELESS_RADIUS = 0.1; // × rendered width — fallback, and the cap for `frameCornerRadiusRatio`
|
|
407
|
+
const FRAMELESS_RADIUS_MIN = 0.03; // × rendered width — floor for a device with squarer screen corners (e.g. iPad)
|
|
401
408
|
const ZOOM_WIDTH = 0.88; // × W
|
|
402
409
|
const ZOOM_RADIUS = 0.05; // × W
|
|
403
410
|
const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
|
|
@@ -419,6 +426,15 @@ export const CALLOUT_MAG = { min: 1.6, max: 2.2, floor: 1.25 } as const;
|
|
|
419
426
|
*/
|
|
420
427
|
export const CALLOUT_MAX_FOCUS_COVER = 0.35;
|
|
421
428
|
const CALLOUT_RADIUS = 0.035; // × W
|
|
429
|
+
/**
|
|
430
|
+
* Padding added around the requested callout crop (screenshot px), as a multiple of the rounded
|
|
431
|
+
* mask's LOCAL corner radius (`CALLOUT_RADIUS * W`, converted to the crop's own pixel space — see
|
|
432
|
+
* `calloutGeometry`), so the rounded corners of the clip mask (`makeCalloutLayer`) don't cut into
|
|
433
|
+
* content at the requested crop's own corners (e.g. selection handles). The geometric minimum to
|
|
434
|
+
* keep a corner point inside a rounded corner of radius `rx` is `1 - 1/√2 ≈ 0.293`; the extra margin
|
|
435
|
+
* covers the fact that padding the crop shrinks the eventual magnification a little further.
|
|
436
|
+
*/
|
|
437
|
+
const CALLOUT_CORNER_PAD = 0.35;
|
|
422
438
|
// Auto crop: fractions of the screenshot width (left-aligned — UI rows start at the left, so a cut
|
|
423
439
|
// at the right loses a chevron, not the first letters of a label); card h ÷ w.
|
|
424
440
|
const CALLOUT_AUTO = { x: 0.02, w: 0.64, aspect: 0.4 };
|
|
@@ -1041,6 +1057,19 @@ function shadowFor(W: number, scale: number, look: Look, spec: { blur: number; o
|
|
|
1041
1057
|
return { color: look.shadowColor, blur: (spec.blur * W) / scale, offsetX: 0, offsetY: (spec.offsetY * W) / scale };
|
|
1042
1058
|
}
|
|
1043
1059
|
|
|
1060
|
+
/**
|
|
1061
|
+
* Non-zoom frameless corner radius, as a ratio of the rendered width: the device's own screen corner
|
|
1062
|
+
* radius (relative to its screen width), clamped to [FRAMELESS_RADIUS_MIN, FRAMELESS_RADIUS]. A flat
|
|
1063
|
+
* FRAMELESS_RADIUS reads fine on an iPhone (its screen corners are nearly that round) but is far too
|
|
1064
|
+
* round on an iPad (squarer corners, wide screen) — round enough to eat into the status bar. Falls
|
|
1065
|
+
* back to FRAMELESS_RADIUS when the device is unknown.
|
|
1066
|
+
*/
|
|
1067
|
+
function frameCornerRadiusRatio(deviceId: string): number {
|
|
1068
|
+
const device = getDeviceFrame(deviceId);
|
|
1069
|
+
if (!device) return FRAMELESS_RADIUS;
|
|
1070
|
+
return Math.min(FRAMELESS_RADIUS, Math.max(FRAMELESS_RADIUS_MIN, device.cornerRadius / device.screenBounds.width));
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1044
1073
|
/** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
|
|
1045
1074
|
function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look): LayerJSON {
|
|
1046
1075
|
const id = generateLayerId();
|
|
@@ -1048,7 +1077,7 @@ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look):
|
|
|
1048
1077
|
const zoom = p.zoom;
|
|
1049
1078
|
const width = zoom ? zoom.cropW : shot.width;
|
|
1050
1079
|
const height = zoom ? zoom.cropH : shot.height;
|
|
1051
|
-
const radius = zoom ? ZOOM_RADIUS * r.W :
|
|
1080
|
+
const radius = zoom ? ZOOM_RADIUS * r.W : frameCornerRadiusRatio(r.plan.deviceId) * width * p.scale;
|
|
1052
1081
|
const fabricData: Record<string, unknown> = {
|
|
1053
1082
|
type: 'image',
|
|
1054
1083
|
src: shot.url,
|
|
@@ -1578,7 +1607,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
|
|
|
1578
1607
|
rects: TextRects;
|
|
1579
1608
|
text: string;
|
|
1580
1609
|
subText?: string;
|
|
1581
|
-
callout?: { rect: Rect; mag: number; crop: { x: number; y: number; w: number; h: number }; scale: number; focusCover: number };
|
|
1610
|
+
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
1611
|
focusCore?: Rect;
|
|
1583
1612
|
mascot?: { rect: Rect; scale: number; flip: boolean; art: ComposeArt; seamPartner?: number; dx?: number };
|
|
1584
1613
|
}
|
|
@@ -2000,6 +2029,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
|
|
|
2000
2029
|
tangent: inTangentZone(overshoot, p.boxHeight, H),
|
|
2001
2030
|
headlineSize: typo.headlineSize / W,
|
|
2002
2031
|
headlineTop: g.headlineTop / H,
|
|
2032
|
+
textBottom: (g.areaTop + typo.textArea) / H,
|
|
2003
2033
|
tilt: p.angle,
|
|
2004
2034
|
centerX: cx / W,
|
|
2005
2035
|
role: r.role,
|
|
@@ -2101,21 +2131,49 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
|
|
|
2101
2131
|
const req = r.calloutReq!;
|
|
2102
2132
|
const { W, H } = r;
|
|
2103
2133
|
const shot = r.plan.screenshot;
|
|
2104
|
-
const
|
|
2134
|
+
const requested = { x: req.x * shot.width, y: req.y * shot.height, w: req.w * shot.width, h: req.h * shot.height };
|
|
2105
2135
|
const scr = p.subject.screen;
|
|
2106
2136
|
// Canvas px per screenshot px as the editor fits it into the screen bounds (frameless: the image).
|
|
2107
2137
|
const screenScale = (p.scale * scr.width) / shot.width;
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
{ cx, cy: p.cy, scale: p.scale, angle: p.angle }
|
|
2112
|
-
);
|
|
2138
|
+
// Vertical band the card must fit in — computed before padding since it only depends on the text
|
|
2139
|
+
// layout, not the crop, and the padding estimate below needs it too (the same vertical fit cap
|
|
2140
|
+
// applies to both).
|
|
2113
2141
|
const texts = textRectList(rects);
|
|
2114
2142
|
const gap = CALLOUT_TEXT_GAP * H;
|
|
2115
2143
|
const [lo, hi] =
|
|
2116
2144
|
r.layout === 'text-bottom'
|
|
2117
2145
|
? [CALLOUT_EDGE * H, Math.min(...texts.map((t) => t.top)) - gap]
|
|
2118
2146
|
: [Math.max(...texts.map((t) => t.bottom)) + gap, H * (1 - CALLOUT_EDGE)];
|
|
2147
|
+
// Pad the requested crop on sides that have room inside the screenshot, so its own corners land
|
|
2148
|
+
// inside the rounded clip mask (`makeCalloutLayer`) instead of being cut off there. `pad` is sized
|
|
2149
|
+
// from a PRELIMINARY magnification: the same formula AND the same fit caps (side margins, vertical
|
|
2150
|
+
// room) used below, run on the unpadded crop — since the final mag depends on the padded crop's own
|
|
2151
|
+
// size, this is a deterministic chicken-and-egg break, not the exact final mag. The placement
|
|
2152
|
+
// search below can still shrink the final mag further (retrying smaller sizes when every position
|
|
2153
|
+
// hides too much of the focus band), so `makeCalloutLayer` also hard-caps the mask's own corner
|
|
2154
|
+
// radius as a backstop — the padding here just keeps that cap from binding in the common case.
|
|
2155
|
+
const baseWReq = requested.w * screenScale;
|
|
2156
|
+
const baseHReq = requested.h * screenScale;
|
|
2157
|
+
let prelimMag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseWReq));
|
|
2158
|
+
prelimMag = Math.min(prelimMag, ((1 - 2 * CALLOUT_MARGIN) * W) / baseWReq, (hi - lo) / baseHReq);
|
|
2159
|
+
// Guard against a degenerate (non-positive) estimate — e.g. no vertical room at all — blowing up
|
|
2160
|
+
// `pad`; the real computation below bails out (`callout-skipped`) in that same case anyway, so the
|
|
2161
|
+
// crop's exact shape no longer matters once that happens.
|
|
2162
|
+
prelimMag = Math.max(prelimMag, 1e-6);
|
|
2163
|
+
const pad = (CALLOUT_CORNER_PAD * CALLOUT_RADIUS * W) / (screenScale * prelimMag);
|
|
2164
|
+
const padX0 = Math.max(0, requested.x - pad);
|
|
2165
|
+
const padY0 = Math.max(0, requested.y - pad);
|
|
2166
|
+
const crop = {
|
|
2167
|
+
x: padX0,
|
|
2168
|
+
y: padY0,
|
|
2169
|
+
w: Math.min(shot.width, requested.x + requested.w + pad) - padX0,
|
|
2170
|
+
h: Math.min(shot.height, requested.y + requested.h + pad) - padY0
|
|
2171
|
+
};
|
|
2172
|
+
const centre = subjectPointToCanvas(
|
|
2173
|
+
p.subject,
|
|
2174
|
+
{ x: scr.x + ((crop.x + crop.w / 2) / shot.width) * scr.width, y: scr.y + ((crop.y + crop.h / 2) / shot.height) * scr.height },
|
|
2175
|
+
{ cx, cy: p.cy, scale: p.scale, angle: p.angle }
|
|
2176
|
+
);
|
|
2119
2177
|
const baseW = crop.w * screenScale;
|
|
2120
2178
|
const baseH = crop.h * screenScale;
|
|
2121
2179
|
let mag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseW));
|
|
@@ -2165,7 +2223,7 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
|
|
|
2165
2223
|
}
|
|
2166
2224
|
}
|
|
2167
2225
|
}
|
|
2168
|
-
if (bestRect) return { rect: bestRect, mag: k, crop, scale: screenScale * k, focusCover: cover(bestRect) };
|
|
2226
|
+
if (bestRect) return { rect: bestRect, mag: k, crop, requested, scale: screenScale * k, focusCover: cover(bestRect) };
|
|
2169
2227
|
if (k <= CALLOUT_MAG.floor) break;
|
|
2170
2228
|
}
|
|
2171
2229
|
warnings.push({
|
|
@@ -2178,7 +2236,24 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
|
|
|
2178
2236
|
|
|
2179
2237
|
function makeCalloutLayer(r: ResolvedScreen, c: NonNullable<ReturnType<typeof calloutGeometry>>, look: Look): LayerJSON {
|
|
2180
2238
|
const id = generateLayerId();
|
|
2181
|
-
|
|
2239
|
+
// Hard guarantee, independent of how `calloutGeometry`'s padding estimate turned out (it's only a
|
|
2240
|
+
// preliminary-magnification guess — the placement search can shrink the final mag further): the
|
|
2241
|
+
// mask's corner radius can never clip the REQUESTED crop's own corners. On each side that had room
|
|
2242
|
+
// to pad (not clamped flush to the screenshot edge — a clamped side had no padding and was always
|
|
2243
|
+
// going to clip there, mask or no mask), the actual padding applied is the gap between `crop` and
|
|
2244
|
+
// `requested`. The geometric minimum margin to keep a corner inside a rounded corner of local
|
|
2245
|
+
// radius `rx` is `(1 − 1/√2) · rx`, so `rx` is capped at the smallest such padding ÷ (1 − 1/√2) —
|
|
2246
|
+
// converted back to a canvas-unit radius via `c.scale`. If every side is clamped (the crop already
|
|
2247
|
+
// spans the screenshot), there's no padding to guarantee anything from, so the normal radius stands.
|
|
2248
|
+
const shot = r.plan.screenshot;
|
|
2249
|
+
const EPS = 1e-6;
|
|
2250
|
+
const pads: number[] = [];
|
|
2251
|
+
if (c.crop.x > EPS) pads.push(c.requested.x - c.crop.x);
|
|
2252
|
+
if (c.crop.y > EPS) pads.push(c.requested.y - c.crop.y);
|
|
2253
|
+
if (c.crop.x + c.crop.w < shot.width - EPS) pads.push(c.crop.x + c.crop.w - (c.requested.x + c.requested.w));
|
|
2254
|
+
if (c.crop.y + c.crop.h < shot.height - EPS) pads.push(c.crop.y + c.crop.h - (c.requested.y + c.requested.h));
|
|
2255
|
+
const CORNER_INSET = 1 - 1 / Math.SQRT2; // ≈0.293 — geometric minimum margin to keep a corner inside a rounded corner of radius rx
|
|
2256
|
+
const radius = pads.length ? Math.min(CALLOUT_RADIUS * r.W, (Math.min(...pads) / CORNER_INSET) * c.scale) : CALLOUT_RADIUS * r.W;
|
|
2182
2257
|
const fabricData: Record<string, unknown> = {
|
|
2183
2258
|
type: 'image',
|
|
2184
2259
|
src: r.plan.screenshot.url,
|
package/src/layout-system.ts
CHANGED
|
@@ -17,7 +17,11 @@ export const NO_TANGENT = {
|
|
|
17
17
|
clearGap: 0.04,
|
|
18
18
|
/** A subject that bleeds must lose at least this × its own (rotated) height. */
|
|
19
19
|
minBleed: 0.12,
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* `bleed: "deep"` target, × the subject's height. Reached by growing the subject (up to
|
|
22
|
+
* `maxWidth`·W) — never by shifting it down past a decisive (`minBleed`) overshoot, since a
|
|
23
|
+
* further down-shift would just be empty space between the text and the subject.
|
|
24
|
+
*/
|
|
21
25
|
deepBleed: 0.25,
|
|
22
26
|
/** The focus band must end at least this × H inside the canvas edge. */
|
|
23
27
|
focusSafe: 0.02,
|
|
@@ -207,10 +211,15 @@ export interface VerticalResult {
|
|
|
207
211
|
* ≥ `clearGap`·H or bleeds by ≥ `minBleed` of the subject's height — never in between — and never
|
|
208
212
|
* pushes the focus band off-canvas. Options:
|
|
209
213
|
* - CLEAR: keep the near edge, shrink until the far edge clears with the margin.
|
|
210
|
-
* - BLEED: grow (up to
|
|
211
|
-
* the
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
+
* - BLEED: grow (up to its size cap) toward the target overshoot; only when growth alone can't get
|
|
215
|
+
* there (the size cap bites first) does the subject shift toward the far edge, and then only far
|
|
216
|
+
* enough for a decisive (`minBleed`) overshoot — never further just to reach a deeper target, which
|
|
217
|
+
* would otherwise leave empty space between the text and the subject. If the result hides the focus
|
|
218
|
+
* band, the placement instead targets the smallest decisive bleed (`minBleed`) that stays focus-safe
|
|
219
|
+
* — or near0 itself, when near0 already sits further down than that — else the bleed option is
|
|
220
|
+
* unavailable.
|
|
221
|
+
* `none` → always clear. `deep` → grow toward `deepBleed` (≥ `minBleed` at the width cap, ≥ `deepBleed`
|
|
222
|
+
* whenever growth alone reaches it), or the most the focus allows, else clear.
|
|
214
223
|
* `auto` → keep the natural placement when it already clears by the margin or is a focus-safe
|
|
215
224
|
* decisive bleed; otherwise (the tangent zone) PREFER the bleed option, and fall back to clear only
|
|
216
225
|
* when every ≥ `minBleed` bleed would crop the focus band. `preferBleed` (tilt) also turns a natural
|
|
@@ -240,24 +249,36 @@ export function solveVertical(inp: VerticalInput): VerticalResult {
|
|
|
240
249
|
return make(s, near0, s < s0 - EPS ? 'shrunk to clear the edge with a margin' : 'clears the edge');
|
|
241
250
|
};
|
|
242
251
|
|
|
243
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). Growing
|
|
254
|
+
* the subject (up to its size cap — the width cap, or a tighter `scaleCap` when one binds) is
|
|
255
|
+
* always tried first. Only when that growth is capped short of t does the subject shift toward the
|
|
256
|
+
* far edge — and even then, only far enough for a decisive (`minBleed`) overshoot, never all the
|
|
257
|
+
* way to t: a deeper target like `deepBleed` is something to grow toward, not something worth
|
|
258
|
+
* shifting the subject away from the text to force.
|
|
259
|
+
*/
|
|
244
260
|
const bleedOption = (t: number): VerticalResult | null => {
|
|
245
261
|
let s = s0;
|
|
246
262
|
let a = near0;
|
|
247
263
|
if (overshoot(s, a) < t * s * eh - EPS) {
|
|
248
264
|
const need = (H - a) / ((1 - t) * eh);
|
|
249
265
|
s = Math.max(s0, Math.min(need, Math.max(sCap, s0)));
|
|
250
|
-
if (overshoot(s, a) <
|
|
266
|
+
if (overshoot(s, a) < NO_TANGENT.minBleed * s * eh - EPS) a = H - s * eh * (1 - NO_TANGENT.minBleed);
|
|
251
267
|
}
|
|
252
|
-
|
|
268
|
+
const reason =
|
|
269
|
+
overshoot(s, a) >= t * s * eh - EPS
|
|
270
|
+
? `bleeds ${Math.round(t * 100)}%+`
|
|
271
|
+
: `grows to its size cap (no gap forced to reach ${Math.round(t * 100)}%+)`;
|
|
272
|
+
if (focusOk(s, a)) return make(s, a, reason);
|
|
253
273
|
// Focus-constrained: keep the scale (≤ what the focus allows at near0) and bleed only as deep
|
|
254
|
-
// as the focus band allows — accepted if that is still a decisive (≥ minBleed) bleed.
|
|
274
|
+
// as the focus band allows — accepted if that is still a decisive (≥ minBleed) bleed. The shift
|
|
275
|
+
// itself only ever targets minBleed (never t), for the same no-gap reason as above.
|
|
255
276
|
if (inp.focusReach == null) return null;
|
|
256
277
|
const fr = inp.focusReach;
|
|
257
278
|
const sF = Math.min(s, Math.max(sCap, s0), (focusLimit - near0) / fr);
|
|
258
279
|
const denom = eh * (1 - NO_TANGENT.minBleed) - fr;
|
|
259
280
|
if (!(sF > 0) || denom <= 0 || sF < (H - focusLimit) / denom - EPS) return null;
|
|
260
|
-
const aF = Math.max(near0, Math.min(focusLimit - sF * fr, H - sF * eh * (1 -
|
|
281
|
+
const aF = Math.max(near0, Math.min(focusLimit - sF * fr, H - sF * eh * (1 - NO_TANGENT.minBleed)));
|
|
261
282
|
if (overshoot(sF, aF) < NO_TANGENT.minBleed * sF * eh - EPS || !focusOk(sF, aF)) return null;
|
|
262
283
|
return make(sF, aF, 'bleed reduced to keep the focus band visible');
|
|
263
284
|
};
|