@appshoteditor/shot-dsl 0.3.0 → 0.5.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/src/decor.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Decoration geometry for the composer (shot-dsl 0.5.0): panorama motifs (one Fabric `Path` that
3
+ * runs across a whole span, offset per screen like the span background) and the rectangle tests the
4
+ * callout / mascot placement uses. Pure math + plain JSON; no DSL layers are validated here.
5
+ */
6
+
7
+ export interface Rect {
8
+ left: number;
9
+ top: number;
10
+ right: number;
11
+ bottom: number;
12
+ }
13
+
14
+ export const rectOf = (cx: number, cy: number, w: number, h: number): Rect => ({ left: cx - w / 2, top: cy - h / 2, right: cx + w / 2, bottom: cy + h / 2 });
15
+
16
+ export const overlaps = (a: Rect, b: Rect, pad = 0): boolean =>
17
+ a.left < b.right + pad && b.left < a.right + pad && a.top < b.bottom + pad && b.top < a.bottom + pad;
18
+
19
+ export const insideRect = (inner: Rect, outer: Rect, eps = 1e-6): boolean =>
20
+ inner.left >= outer.left - eps && inner.right <= outer.right + eps && inner.top >= outer.top - eps && inner.bottom <= outer.bottom + eps;
21
+
22
+ const area = (r: Rect) => Math.max(0, r.right - r.left) * Math.max(0, r.bottom - r.top);
23
+ const intersect = (a: Rect, b: Rect): Rect => ({ left: Math.max(a.left, b.left), top: Math.max(a.top, b.top), right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom) });
24
+
25
+ /**
26
+ * Share (0–1) of the focus band `focus` that `card` hides, NOT counting the part of the band the card
27
+ * magnifies itself (`source`): (card ∩ focus − card ∩ focus ∩ source) ÷ (focus − focus ∩ source).
28
+ */
29
+ export function focusCoverage(card: Rect, focus: Rect, source: Rect): number {
30
+ const fs = intersect(focus, source);
31
+ const rest = area(focus) - area(fs);
32
+ if (rest <= 1e-9) return 0;
33
+ const cf = intersect(card, focus);
34
+ return Math.max(0, area(cf) - area(intersect(cf, source))) / rest;
35
+ }
36
+
37
+ export type PathCommand = [string, ...number[]];
38
+
39
+ export type Motif = 'orbs' | 'none' | 'honeycomb' | 'wave';
40
+ export const MOTIFS: readonly Motif[] = ['orbs', 'none', 'honeycomb', 'wave'];
41
+
42
+ /**
43
+ * A motif across a span `spanWidth` × H, as SUBPATHS (each a list of path commands) in SPAN
44
+ * coordinates, deterministic.
45
+ * - `honeycomb`: hexagon outlines along a gentle sine band that crosses every seam.
46
+ * - `wave`: three flowing parallel curves across the whole span.
47
+ */
48
+ export function motifSubpaths(motif: Motif, spanWidth: number, W: number, H: number): PathCommand[][] {
49
+ const subpaths: PathCommand[][] = [];
50
+ if (motif === 'honeycomb') {
51
+ const r = 0.085 * W; // hexagon circumradius
52
+ const dx = Math.sqrt(3) * r;
53
+ const dy = 1.5 * r;
54
+ const centre = (x: number) => H * 0.62 + H * 0.12 * Math.sin((2 * Math.PI * x) / (1.6 * W) + 0.6);
55
+ for (let row = -6; row <= 6; row++) {
56
+ const y = H * 0.62 + row * dy;
57
+ for (let col = -1; col * dx <= spanWidth + dx; col++) {
58
+ const x = col * dx + (row % 2 ? dx / 2 : 0);
59
+ // Keep a band ~2.6 cells tall around the sine centre line, thinning at its edges.
60
+ const d = Math.abs(y - centre(x)) / dy;
61
+ if (d > 1.3) continue;
62
+ const rr = d > 0.8 ? r * 0.62 : r * 0.9;
63
+ const hex: PathCommand[] = [];
64
+ for (let k = 0; k < 6; k++) {
65
+ const a = (Math.PI / 3) * k + Math.PI / 6;
66
+ hex.push([k === 0 ? 'M' : 'L', x + rr * Math.cos(a), y + rr * Math.sin(a)]);
67
+ }
68
+ hex.push(['Z']);
69
+ subpaths.push(hex);
70
+ }
71
+ }
72
+ } else if (motif === 'wave') {
73
+ const steps = Math.max(24, Math.ceil(spanWidth / (0.04 * W)));
74
+ for (const [k, base] of [0.5, 0.58, 0.66].entries()) {
75
+ const line: PathCommand[] = [];
76
+ for (let i = 0; i <= steps; i++) {
77
+ const x = (i / steps) * spanWidth;
78
+ const y = H * base + H * 0.06 * Math.sin((2 * Math.PI * x) / (1.4 * W) + k * 0.7);
79
+ line.push([i === 0 ? 'M' : 'L', x, y]);
80
+ }
81
+ subpaths.push(line);
82
+ }
83
+ }
84
+ return subpaths;
85
+ }
86
+
87
+ /** Path coordinate precision (canvas units): 0.1 keeps seams exact to well under a device pixel. */
88
+ const MOTIF_PRECISION = 10;
89
+ const round1 = (v: number) => Math.round(v * MOTIF_PRECISION) / MOTIF_PRECISION;
90
+
91
+ /**
92
+ * The part of a span-wide motif that span screen `k` shows, in THAT screen's coordinates: only the
93
+ * subpaths (hexagons) touching [−pad, W + pad], and for open polylines only the segments that do
94
+ * (plus the segment crossing each window edge, so strokes run continuously over the seam).
95
+ * Coordinates are rounded to 0.1 unit. Both neighbours emit the geometry around their shared seam
96
+ * from the same span-space points (rounded after the same −k·W shift of an integer-width offset),
97
+ * so the halves meet exactly. Returns the commands and their bounding box (Fabric positions a Path
98
+ * by its bbox centre), or null when nothing is visible.
99
+ */
100
+ export function motifPathForScreen(subpaths: PathCommand[][], k: number, W: number, pad: number): { path: PathCommand[]; bbox: Rect } | null {
101
+ const lo = k * W - pad;
102
+ const hi = (k + 1) * W + pad;
103
+ const out: PathCommand[] = [];
104
+ for (const sub of subpaths) {
105
+ const pts = sub.filter((c) => c.length >= 3) as Array<[string, number, number]>;
106
+ if (!pts.length) continue;
107
+ const closed = sub.some((c) => c[0] === 'Z');
108
+ if (closed) {
109
+ const xs = pts.map((c) => c[1]);
110
+ if (Math.max(...xs) < lo || Math.min(...xs) > hi) continue;
111
+ sub.forEach((c) => out.push(c.length >= 3 ? [c[0], round1((c[1] as number) - k * W), round1(c[2] as number)] : [c[0]]));
112
+ continue;
113
+ }
114
+ // Open polyline: keep point i when it or a neighbour lies inside the window.
115
+ const inside = pts.map((c) => c[1] >= lo && c[1] <= hi);
116
+ let pen = false;
117
+ pts.forEach((c, i) => {
118
+ const keep = inside[i] || (i > 0 && inside[i - 1]) || (i < pts.length - 1 && inside[i + 1]);
119
+ if (!keep) {
120
+ pen = false;
121
+ return;
122
+ }
123
+ out.push([pen ? 'L' : 'M', round1(c[1] - k * W), round1(c[2])]);
124
+ pen = true;
125
+ });
126
+ }
127
+ const xs: number[] = [];
128
+ const ys: number[] = [];
129
+ for (const c of out) {
130
+ if (c.length >= 3) {
131
+ xs.push(c[1] as number);
132
+ ys.push(c[2] as number);
133
+ }
134
+ }
135
+ if (!xs.length) return null;
136
+ return { path: out, bbox: { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) } };
137
+ }
package/src/frames.ts CHANGED
@@ -45,6 +45,8 @@ export function makeDeviceFrameLayer(opts: {
45
45
  centerY?: number;
46
46
  scale?: number;
47
47
  screenshotRotation?: number;
48
+ /** Clockwise tilt of the whole mockup in degrees (the editor rotates the screenshot with it). */
49
+ angle?: number;
48
50
  name?: string;
49
51
  }): LayerJSON {
50
52
  const device = getDeviceFrame(opts.deviceId);
@@ -75,6 +77,7 @@ export function makeDeviceFrameLayer(opts: {
75
77
  deviceId: opts.deviceId,
76
78
  deviceScale: scale
77
79
  };
80
+ if (opts.angle) fabricData.angle = opts.angle;
78
81
 
79
82
  if (opts.screenshotUrl) {
80
83
  const { screenshotWidth: width, screenshotHeight: height } = opts;
package/src/index.ts CHANGED
@@ -6,3 +6,9 @@ export * from './builders';
6
6
  export * from './device-frames';
7
7
  export * from './frames';
8
8
  export * from './compose';
9
+ export * from './layout-system';
10
+ export * from './color';
11
+ export * from './variants';
12
+ export * from './typography';
13
+ export * from './palette';
14
+ export * from './decor';
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Set-wide layout geometry for the composer (shot-dsl 0.4.0): the no-tangent rule, focus-aware
3
+ * bleed, rotated bounding boxes and seam checks. Pure math — no DSL objects are built here, so every
4
+ * branch is unit-testable in isolation (see layout-system.test.ts).
5
+ *
6
+ * Vocabulary (all canvas units unless noted):
7
+ * - SUBJECT: what sits under the text — a device frame PNG, or the bare screenshot (frameless).
8
+ * `width/height` are its natural (unscaled) pixel size; `screen` is the screenshot's area inside it.
9
+ * - NEAR edge: the subject's edge facing the text block (top for `text-top`, bottom for `text-bottom`).
10
+ * FAR edge: the opposite one, which may bleed off the canvas.
11
+ * - OVERSHOOT: how far the FAR edge passes the canvas edge (> 0 = bleeds off, < 0 = gap).
12
+ */
13
+
14
+ /** The "pro rules" thresholds. */
15
+ export const NO_TANGENT = {
16
+ /** A subject that stays on-canvas must clear the edge by at least this × H. */
17
+ clearGap: 0.04,
18
+ /** A subject that bleeds must lose at least this × its own (rotated) height. */
19
+ minBleed: 0.12,
20
+ /** `bleed: "deep"` target, × the subject's height. */
21
+ deepBleed: 0.25,
22
+ /** The focus band must end at least this × H inside the canvas edge. */
23
+ focusSafe: 0.02,
24
+ /** Upper bound on the subject's (unrotated) width — targets are clamped to it too, × W. */
25
+ maxWidth: 0.9,
26
+ /**
27
+ * Horizontal no-tangent rule: the subject's VISIBLE extent keeps at least this × W from each side
28
+ * edge (a side bleed is only allowed as a deliberate panorama straddle across a seam).
29
+ */
30
+ sideMargin: 0.05,
31
+ /** The focus band's (rotated) corners stay at least this × W inside the side edges / seams. */
32
+ focusSideSafe: 0.02,
33
+ /** A clear-with-margin option that shrinks the subject below this × its natural scale is rejected. */
34
+ minClearScale: 0.6,
35
+ /** Below this × the layout's target scale the copy leaves no usable room (composeSet throws). */
36
+ minScale: 0.5
37
+ } as const;
38
+
39
+ const EPS = 1e-6;
40
+
41
+ export type BleedPreference = 'auto' | 'none' | 'deep';
42
+
43
+ /** Fractions of the screenshot height (0 = top, 1 = bottom) that must stay visible. */
44
+ export interface FocusBand {
45
+ top: number;
46
+ bottom: number;
47
+ }
48
+
49
+ export interface Subject {
50
+ width: number;
51
+ height: number;
52
+ /** Where the screenshot sits inside the subject (natural px). Frameless: the whole image. */
53
+ screen: { x: number; y: number; width: number; height: number };
54
+ }
55
+
56
+ export interface Point {
57
+ x: number;
58
+ y: number;
59
+ }
60
+
61
+ /** Axis-aligned bounding box of a w×h rectangle rotated by `angle` degrees. */
62
+ export function rotatedBox(width: number, height: number, angle = 0): { width: number; height: number } {
63
+ const r = (angle * Math.PI) / 180;
64
+ const c = Math.abs(Math.cos(r));
65
+ const s = Math.abs(Math.sin(r));
66
+ return { width: width * c + height * s, height: width * s + height * c };
67
+ }
68
+
69
+ /**
70
+ * Canvas position of a point given in the subject's natural px (from its top-left), for a subject
71
+ * drawn centre-origin at (cx, cy), scaled by `scale` and rotated clockwise by `angle` (Fabric).
72
+ */
73
+ export function subjectPointToCanvas(
74
+ subject: Subject,
75
+ p: Point,
76
+ pose: { cx: number; cy: number; scale: number; angle?: number }
77
+ ): Point {
78
+ const r = ((pose.angle ?? 0) * Math.PI) / 180;
79
+ const lx = (p.x - subject.width / 2) * pose.scale;
80
+ const ly = (p.y - subject.height / 2) * pose.scale;
81
+ return {
82
+ x: pose.cx + lx * Math.cos(r) - ly * Math.sin(r),
83
+ y: pose.cy + lx * Math.sin(r) + ly * Math.cos(r)
84
+ };
85
+ }
86
+
87
+ /** The focus band's four corners in the subject's natural px (full screen width, band height). */
88
+ export function focusCorners(subject: Subject, focus: FocusBand): Point[] {
89
+ const { x, y, width, height } = subject.screen;
90
+ const top = y + focus.top * height;
91
+ const bottom = y + focus.bottom * height;
92
+ return [
93
+ { x, y: top },
94
+ { x: x + width, y: top },
95
+ { x: x + width, y: bottom },
96
+ { x, y: bottom }
97
+ ];
98
+ }
99
+
100
+ /**
101
+ * How far (natural px, i.e. at scale 1) the focus band reaches from the subject's rotated-AABB NEAR
102
+ * edge toward its FAR edge. The subject keeps its focus visible iff
103
+ * `near + scale · focusReach ≤ H · (1 − focusSafe)`.
104
+ */
105
+ export function focusReach(subject: Subject, focus: FocusBand, angle: number, farEdge: 'bottom' | 'top'): number {
106
+ const box = rotatedBox(subject.width, subject.height, angle);
107
+ // Rotate about the centre at scale 1, measure y from the AABB centre.
108
+ const ys = focusCorners(subject, focus).map((p) => subjectPointToCanvas(subject, p, { cx: 0, cy: 0, scale: 1, angle }).y);
109
+ return farEdge === 'bottom' ? Math.max(...ys) + box.height / 2 : box.height / 2 - Math.min(...ys);
110
+ }
111
+
112
+ /** The subject's four corners (tl, tr, br, bl) on the canvas for a pose. */
113
+ export function subjectCorners(subject: Subject, pose: { cx: number; cy: number; scale: number; angle?: number }): Point[] {
114
+ return [
115
+ { x: 0, y: 0 },
116
+ { x: subject.width, y: 0 },
117
+ { x: subject.width, y: subject.height },
118
+ { x: 0, y: subject.height }
119
+ ].map((p) => subjectPointToCanvas(subject, p, pose));
120
+ }
121
+
122
+ /** Clip a convex polygon to the horizontal band y0 ≤ y ≤ y1 (Sutherland–Hodgman, two edges). */
123
+ export function clipPolygonToBand(points: Point[], y0: number, y1: number): Point[] {
124
+ const clip = (pts: Point[], inside: (p: Point) => boolean, cut: (a: Point, b: Point) => Point) => {
125
+ const out: Point[] = [];
126
+ pts.forEach((cur, i) => {
127
+ const prev = pts[(i + pts.length - 1) % pts.length];
128
+ if (inside(cur)) {
129
+ if (!inside(prev)) out.push(cut(prev, cur));
130
+ out.push(cur);
131
+ } else if (inside(prev)) out.push(cut(prev, cur));
132
+ });
133
+ return out;
134
+ };
135
+ const atY = (y: number) => (a: Point, b: Point): Point => ({ x: a.x + ((b.x - a.x) * (y - a.y)) / (b.y - a.y), y });
136
+ const top = clip(points, (p) => p.y >= y0, atY(y0));
137
+ return top.length ? clip(top, (p) => p.y <= y1, atY(y1)) : [];
138
+ }
139
+
140
+ /** Horizontal extent of the part of the subject that is actually on the canvas (null if none). */
141
+ export function visibleXExtent(
142
+ subject: Subject,
143
+ pose: { cx: number; cy: number; scale: number; angle?: number },
144
+ canvasHeight: number
145
+ ): { min: number; max: number } | null {
146
+ const poly = clipPolygonToBand(subjectCorners(subject, pose), 0, canvasHeight);
147
+ if (poly.length === 0) return null;
148
+ const xs = poly.map((p) => p.x);
149
+ return { min: Math.min(...xs), max: Math.max(...xs) };
150
+ }
151
+
152
+ /** Horizontal extent of the focus band's rotated corners. */
153
+ export function focusXExtent(
154
+ subject: Subject,
155
+ focus: FocusBand,
156
+ pose: { cx: number; cy: number; scale: number; angle?: number }
157
+ ): { min: number; max: number } {
158
+ const xs = focusCorners(subject, focus).map((p) => subjectPointToCanvas(subject, p, pose).x);
159
+ return { min: Math.min(...xs), max: Math.max(...xs) };
160
+ }
161
+
162
+ /** True when a far edge overshoot is in the forbidden "just touching" band. */
163
+ export function inTangentZone(overshoot: number, subjectHeight: number, canvasHeight: number): boolean {
164
+ return overshoot > -NO_TANGENT.clearGap * canvasHeight + EPS && overshoot < NO_TANGENT.minBleed * subjectHeight - EPS;
165
+ }
166
+
167
+ export interface VerticalInput {
168
+ canvasWidth: number;
169
+ canvasHeight: number;
170
+ /** Distance from the text-side canvas edge to where the subject's near edge may start. */
171
+ near: number;
172
+ /** The subject's near edge can't start closer than this (hero layouts push the device lower). */
173
+ minNear?: number;
174
+ /** Unrotated natural width (width targets are expressed on it, so a tilt doesn't shrink the device). */
175
+ baseWidth: number;
176
+ /** Rotated-AABB natural height (vertical extent). */
177
+ boxHeight: number;
178
+ /** Target rendered width, × W. */
179
+ targetWidth: number;
180
+ /** The natural scale is capped so no more than this fraction of the subject is off-canvas. */
181
+ maxBleed: number;
182
+ /** See `focusReach`; null = no focus band marked (unconstrained). */
183
+ focusReach: number | null;
184
+ bleed: BleedPreference;
185
+ /** Tilted subjects: prefer a decisive bleed whenever one is possible (tilt is paired with a bleed). */
186
+ preferBleed?: boolean;
187
+ /** Hard upper bound on the scale (e.g. from the horizontal rules); applied to every option. */
188
+ scaleCap?: number;
189
+ }
190
+
191
+ export interface VerticalResult {
192
+ scale: number;
193
+ /** Distance from the text-side edge to the subject's near edge. */
194
+ near: number;
195
+ /** Far edge overshoot (> 0 bleeds off the canvas). */
196
+ overshoot: number;
197
+ mode: 'clear' | 'bleed';
198
+ /** overshoot ÷ rendered subject height (negative when clear). */
199
+ bleedFraction: number;
200
+ /** Why this option was picked (debug/report aid). */
201
+ reason: string;
202
+ }
203
+
204
+ /**
205
+ * THE no-tangent solver. Given where the subject may start (below the set's reserved text area) and
206
+ * how big it wants to be, return a placement whose far edge either clears the canvas edge by
207
+ * ≥ `clearGap`·H or bleeds by ≥ `minBleed` of the subject's height — never in between — and never
208
+ * pushes the focus band off-canvas. Options:
209
+ * - CLEAR: keep the near edge, shrink until the far edge clears with the margin.
210
+ * - BLEED: grow (up to `maxWidth`·W), then shift toward the far edge, until the overshoot reaches
211
+ * the target; if that hides the focus band, the largest focus-safe bleed ≥ `minBleed` is used,
212
+ * else the bleed option is unavailable.
213
+ * `none` → always clear. `deep` → bleed ≥ `deepBleed` (or the most the focus allows), else clear.
214
+ * `auto` → keep the natural placement when it already clears by the margin or is a focus-safe
215
+ * decisive bleed; otherwise (the tangent zone) PREFER the bleed option, and fall back to clear only
216
+ * when every ≥ `minBleed` bleed would crop the focus band. `preferBleed` (tilt) also turns a natural
217
+ * clear into a bleed.
218
+ */
219
+ export function solveVertical(inp: VerticalInput): VerticalResult {
220
+ const W = inp.canvasWidth;
221
+ const H = inp.canvasHeight;
222
+ const eh = inp.boxHeight;
223
+ const near0 = Math.max(inp.near, inp.minNear ?? 0);
224
+ const cap = inp.scaleCap ?? Infinity;
225
+ const sCap = Math.min((NO_TANGENT.maxWidth * W) / inp.baseWidth, cap);
226
+ const target = Math.min(inp.targetWidth, NO_TANGENT.maxWidth);
227
+ const s0 = Math.max(0, Math.min((target * W) / inp.baseWidth, (H - near0) / (1 - inp.maxBleed) / eh, cap));
228
+ const focusLimit = H * (1 - NO_TANGENT.focusSafe);
229
+
230
+ const overshoot = (s: number, a: number) => a + s * eh - H;
231
+ const focusOk = (s: number, a: number) => inp.focusReach == null || a + s * inp.focusReach <= focusLimit + EPS;
232
+ const make = (s: number, a: number, reason: string): VerticalResult => {
233
+ const o = overshoot(s, a);
234
+ return { scale: s, near: a, overshoot: o, mode: o > 0 ? 'bleed' : 'clear', bleedFraction: o / (s * eh), reason };
235
+ };
236
+
237
+ const clearOption = (): VerticalResult | null => {
238
+ const s = Math.min(s0, (H * (1 - NO_TANGENT.clearGap) - near0) / eh);
239
+ if (!(s > 0) || s < NO_TANGENT.minClearScale * s0 - EPS) return null;
240
+ return make(s, near0, s < s0 - EPS ? 'shrunk to clear the edge with a margin' : 'clears the edge');
241
+ };
242
+
243
+ /** Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). */
244
+ const bleedOption = (t: number): VerticalResult | null => {
245
+ let s = s0;
246
+ let a = near0;
247
+ if (overshoot(s, a) < t * s * eh - EPS) {
248
+ const need = (H - a) / ((1 - t) * eh);
249
+ s = Math.max(s0, Math.min(need, Math.max(sCap, s0)));
250
+ if (overshoot(s, a) < t * s * eh - EPS) a = H - s * eh * (1 - t);
251
+ }
252
+ if (focusOk(s, a)) return make(s, a, `bleeds ${Math.round(t * 100)}%+`);
253
+ // 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.
255
+ if (inp.focusReach == null) return null;
256
+ const fr = inp.focusReach;
257
+ const sF = Math.min(s, Math.max(sCap, s0), (focusLimit - near0) / fr);
258
+ const denom = eh * (1 - NO_TANGENT.minBleed) - fr;
259
+ 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 - t)));
261
+ if (overshoot(sF, aF) < NO_TANGENT.minBleed * sF * eh - EPS || !focusOk(sF, aF)) return null;
262
+ return make(sF, aF, 'bleed reduced to keep the focus band visible');
263
+ };
264
+
265
+ /** Last resort (never a tangent): clear with the margin however small that makes the subject. */
266
+ const forcedClear = () => make(Math.max(0, Math.min(s0, (H * (1 - NO_TANGENT.clearGap) - near0) / eh)), near0, 'forced clear');
267
+
268
+ if (inp.bleed === 'none') return clearOption() ?? forcedClear();
269
+
270
+ const naturalOver = overshoot(s0, near0);
271
+ const naturalClear = naturalOver <= -NO_TANGENT.clearGap * H + EPS;
272
+ const naturalBleed = naturalOver >= NO_TANGENT.minBleed * s0 * eh - EPS;
273
+
274
+ if (inp.bleed === 'deep') {
275
+ const t = Math.max(NO_TANGENT.deepBleed, naturalBleed ? naturalOver / (s0 * eh) : 0);
276
+ return bleedOption(t) ?? clearOption() ?? forcedClear();
277
+ }
278
+
279
+ // auto
280
+ if (naturalClear && !inp.preferBleed) return make(s0, near0, 'natural placement clears the edge');
281
+ if (naturalBleed && focusOk(s0, near0)) return make(s0, near0, 'natural placement bleeds decisively');
282
+ // In the tangent zone (or a tilted subject): resolve by bleeding decisively. Clear-with-margin
283
+ // only when no ≥ minBleed bleed keeps the focus band visible.
284
+ const bleed = bleedOption(NO_TANGENT.minBleed);
285
+ if (bleed) return inp.preferBleed ? { ...bleed, reason: `${bleed.reason} (tilt pairs with a bleed)` } : bleed;
286
+ if (naturalClear) return make(s0, near0, 'natural placement clears the edge');
287
+ return clearOption() ?? forcedClear();
288
+ }
289
+
290
+ /** Does the vertical line x = `seamX` pass through the polygon spanned by `points`? */
291
+ export function crossesVertical(points: Point[], seamX: number, margin = 0): boolean {
292
+ const xs = points.map((p) => p.x);
293
+ return Math.min(...xs) < seamX + margin && Math.max(...xs) > seamX - margin;
294
+ }
295
+
296
+ /** Do two axis-aligned rects overlap? */
297
+ export function rectsOverlap(
298
+ a: { left: number; top: number; right: number; bottom: number },
299
+ b: { left: number; top: number; right: number; bottom: number }
300
+ ): boolean {
301
+ return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
302
+ }
package/src/palette.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Brand palette system (shot-dsl 0.5.0): tonal backgrounds from the app's brand colours and ONE
3
+ * set-wide text colour that passes WCAG AA (≥ 4.5:1) on every style-derived background.
4
+ *
5
+ * - `tonal` (new): `colors: [base, accent?]`, `tone: light | vivid | deep`. Each screen gets a
6
+ * light↔deep step of the base hue as a subtle vertical gradient (`light`: pale tints; `vivid`:
7
+ * around the brand colour; `deep`: richer, darker, warmer steps — still a colour, not a
8
+ * near-black); the text colour is a deep (or pale) tint of the same hue, never a grey.
9
+ * - `family` / `sequence` (0.4.0): unchanged colours, but now also ONE text colour per set.
10
+ *
11
+ * Contrast is enforced by generation: `harmonize` evaluates both text candidates (dark / light),
12
+ * picks the one that needs the smaller tone adjustment, and moves every style-derived background
13
+ * stop away from the text colour (lighter under dark text, deeper under light text) in small
14
+ * steps until the WORST sampled point of every background passes.
15
+ */
16
+ import type { BackgroundJSON, ColorStop } from './types';
17
+ import { contrastRatio, isHexColor, mixHex, parseHex, toHex, DARK_TEXT, LIGHT_TEXT } from './color';
18
+
19
+ export const MIN_CONTRAST = 4.5;
20
+ /** Generation aims a little above AA so rounding never lands a sample at 4.49. */
21
+ const TARGET_CONTRAST = 4.6;
22
+
23
+ export type PaletteTone = 'light' | 'vivid' | 'deep';
24
+
25
+ export interface HSL {
26
+ h: number;
27
+ s: number;
28
+ l: number;
29
+ }
30
+
31
+ export function hexToHsl(hex: string): HSL {
32
+ const c = parseHex(hex) ?? { r: 31, g: 41, b: 55 };
33
+ const r = c.r / 255;
34
+ const g = c.g / 255;
35
+ const b = c.b / 255;
36
+ const max = Math.max(r, g, b);
37
+ const min = Math.min(r, g, b);
38
+ const l = (max + min) / 2;
39
+ if (max === min) return { h: 0, s: 0, l };
40
+ const d = max - min;
41
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
42
+ let h: number;
43
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
44
+ else if (max === g) h = (b - r) / d + 2;
45
+ else h = (r - g) / d + 4;
46
+ return { h: h * 60, s, l };
47
+ }
48
+
49
+ export function hslToHex({ h, s, l }: HSL): string {
50
+ const hh = (((h % 360) + 360) % 360) / 360;
51
+ const ss = Math.min(1, Math.max(0, s));
52
+ const ll = Math.min(1, Math.max(0, l));
53
+ if (ss === 0) return toHex({ r: ll * 255, g: ll * 255, b: ll * 255 });
54
+ const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
55
+ const p = 2 * ll - q;
56
+ const hue = (t: number) => {
57
+ let x = t;
58
+ if (x < 0) x += 1;
59
+ if (x > 1) x -= 1;
60
+ if (x < 1 / 6) return p + (q - p) * 6 * x;
61
+ if (x < 1 / 2) return q;
62
+ if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
63
+ return p;
64
+ };
65
+ return toHex({ r: hue(hh + 1 / 3) * 255, g: hue(hh) * 255, b: hue(hh - 1 / 3) * 255 });
66
+ }
67
+
68
+ /** Lightness steps (and per-step hue drift) of each tone; screen i uses step i % 4, the hero step 0. */
69
+ const TONE_STEPS: Record<PaletteTone, { l: number[]; sat: number; spread: number }> = {
70
+ light: { l: [0.9, 0.93, 0.87, 0.95], sat: 0.85, spread: 0.035 },
71
+ vivid: { l: [0, 0.09, -0.06, 0.15], sat: 1, spread: 0.07 },
72
+ // Deep = richer, darker and warmer than the brand colour, still a colour (not a near-black).
73
+ deep: { l: [0.46, 0.52, 0.42, 0.49], sat: 1, spread: 0.08 }
74
+ };
75
+ const HUE_DRIFT: Record<PaletteTone, number[]> = { light: [0, -5, 4, -8], vivid: [0, -5, 4, -8], deep: [-8, -3, -12, -6] };
76
+
77
+ /**
78
+ * The tonal background of step `step` for `base` (before any contrast shift): a vertical gradient
79
+ * (editor angle 180°: offset 0 = BOTTOM) from a slightly deeper tone at the bottom to a lighter
80
+ * one at the top.
81
+ */
82
+ export function tonalBackground(base: string, tone: PaletteTone, step: number): BackgroundJSON {
83
+ const hsl = hexToHsl(base);
84
+ const spec = TONE_STEPS[tone];
85
+ const k = ((step % 4) + 4) % 4;
86
+ const l = tone === 'vivid' ? Math.min(0.72, Math.max(0.34, hsl.l + spec.l[k])) : spec.l[k];
87
+ const s = Math.min(1, hsl.s * spec.sat);
88
+ const h = hsl.h + HUE_DRIFT[tone][k];
89
+ const stops: ColorStop[] = [
90
+ { offset: 0, color: hslToHex({ h: h + 3, s, l: l - spec.spread }) },
91
+ { offset: 1, color: hslToHex({ h: h - 3, s: s * 0.96, l: l + spec.spread }) }
92
+ ];
93
+ return { type: 'gradient', gradient: { type: 'linear', colorStops: stops, angle: 180 } };
94
+ }
95
+
96
+ /** Text candidates for a palette: a deep tint of the base hue and a pale one (tonal), else neutral. */
97
+ export function textCandidates(base: string | null): { dark: string; light: string } {
98
+ if (!base) return { dark: DARK_TEXT, light: LIGHT_TEXT };
99
+ const { h, s } = hexToHsl(base);
100
+ return {
101
+ dark: hslToHex({ h, s: Math.min(0.75, s * 0.8 + 0.1), l: 0.11 }),
102
+ light: hslToHex({ h, s: Math.min(1, s), l: 0.975 })
103
+ };
104
+ }
105
+
106
+ /** Move a background's stops `t` (0–1) of the way toward `target` (white or black). */
107
+ export function shiftBackground(bg: BackgroundJSON, target: string, t: number): BackgroundJSON {
108
+ if (t <= 0) return bg;
109
+ if (bg.type !== 'gradient' || !bg.gradient) {
110
+ return { ...bg, color: isHexColor(bg.color) ? mixHex(bg.color, target, t) : bg.color };
111
+ }
112
+ const move = (c: string) => (isHexColor(c) ? mixHex(c, target, t) : c);
113
+ return {
114
+ ...bg,
115
+ gradient: {
116
+ ...bg.gradient,
117
+ colorStops: (bg.gradient.colorStops ?? []).map((s) => ({ ...s, color: move(s.color) })),
118
+ ...(bg.gradient.colors ? { colors: bg.gradient.colors.map(move) } : {})
119
+ }
120
+ };
121
+ }
122
+
123
+ /** Worst WCAG contrast of `text` over sampled colours. */
124
+ export function worstContrast(text: string, samples: string[]): number {
125
+ return samples.length ? Math.min(...samples.map((c) => contrastRatio(text, c))) : 21;
126
+ }
127
+
128
+ export interface Harmonized {
129
+ text: string;
130
+ /** Fraction the style-derived backgrounds were moved away from the text colour (0 = untouched). */
131
+ shift: number;
132
+ /** Worst contrast over every shiftable sample after the shift. */
133
+ worst: number;
134
+ /** Worst contrast over the FIXED samples (explicit backgrounds, never recoloured); 21 when none. */
135
+ worstFixed: number;
136
+ }
137
+
138
+ /**
139
+ * Choose the set's text colour and the tone shift that makes it pass everywhere.
140
+ * - `samplesAt(target, shift)`: every sampled colour of the style-derived backgrounds (palette
141
+ * screens, panorama spans), moved toward `target` by `shift`.
142
+ * - `fixed`: sampled colours of explicit backgrounds — they count toward the choice but are never
143
+ * recoloured.
144
+ * Preference: the candidate that reaches AA on the shiftable samples with the least re-toning; the
145
+ * other one instead only if IT also passes on the fixed samples and costs ≤ 0.15 more shift. Failing
146
+ * explicit screens then get their own colour from the caller. `preferLight` breaks exact ties (the
147
+ * 0.4.0 white default for explicit-only sets).
148
+ */
149
+ export function harmonize(
150
+ candidates: { dark: string; light: string },
151
+ samplesAt: (target: string, shift: number) => string[],
152
+ fixed: string[] = [],
153
+ preferLight = false
154
+ ): Harmonized {
155
+ const solve = (text: string, target: string): Harmonized => {
156
+ let shift = 0;
157
+ let worst = worstContrast(text, samplesAt(target, 0));
158
+ for (let i = 0; i < 40 && worst < TARGET_CONTRAST; i++) {
159
+ shift = Math.min(1, shift + 0.025);
160
+ worst = worstContrast(text, samplesAt(target, shift));
161
+ if (shift >= 1) break;
162
+ }
163
+ return { text, shift, worst, worstFixed: worstContrast(text, fixed) };
164
+ };
165
+ const dark = solve(candidates.dark, '#FFFFFF');
166
+ const light = solve(candidates.light, '#000000');
167
+ const passes = (h: Harmonized) => h.worst >= MIN_CONTRAST;
168
+ const passesAll = (h: Harmonized) => passes(h) && h.worstFixed >= MIN_CONTRAST;
169
+ const pick = (a: Harmonized, b: Harmonized) => {
170
+ if (a.shift !== b.shift) return a.shift < b.shift ? a : b;
171
+ if (preferLight) return b;
172
+ return Math.min(a.worst, a.worstFixed) >= Math.min(b.worst, b.worstFixed) ? a : b;
173
+ };
174
+ // Primary: the candidate that makes the palette pass with the least re-toning.
175
+ const primary = passes(dark) && passes(light) ? pick(dark, light) : passes(dark) ? dark : passes(light) ? light : dark.worst >= light.worst ? dark : light;
176
+ if (passesAll(primary)) return primary;
177
+ // The other candidate may also pass on the explicit backgrounds — worth it only if that costs
178
+ // little extra re-toning (a light palette is never turned dark to suit one dark screen; that
179
+ // screen gets its own colour instead).
180
+ const other = primary === dark ? light : dark;
181
+ if (passesAll(other) && other.shift <= primary.shift + MAX_EXTRA_SHIFT_FOR_FIXED) return other;
182
+ return primary;
183
+ }
184
+
185
+ /** How much more palette re-toning is acceptable to get ONE colour that also passes on explicit backgrounds. */
186
+ const MAX_EXTRA_SHIFT_FOR_FIXED = 0.15;