@appshoteditor/shot-dsl 0.4.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/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/index.ts CHANGED
@@ -9,3 +9,6 @@ export * from './compose';
9
9
  export * from './layout-system';
10
10
  export * from './color';
11
11
  export * from './variants';
12
+ export * from './typography';
13
+ export * from './palette';
14
+ export * from './decor';
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;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Line breaking by meaning (shot-dsl 0.5.0). The composer never lets Fabric's greedy wrap decide a
3
+ * headline's shape:
4
+ * - an explicit `\n` in the copy is ALWAYS a line break — lines the author broke are never merged;
5
+ * - a line (explicit or not) that doesn't fit the box is broken into the SAME number of lines the
6
+ * greedy wrap would use, but balanced — minimal raggedness, no single short word stranded on the
7
+ * last line, breaks preferred after punctuation and never right after a short function word.
8
+ * The chosen breaks are emitted as `\n` in the Textbox text, so the editor shows exactly these lines
9
+ * (each fits the box by the conservative width model, so Fabric never re-wraps them).
10
+ *
11
+ * CJK (and other scripts written without spaces) may break between any two of their characters:
12
+ * such characters are tokens of their own, joined without a space.
13
+ */
14
+
15
+ /** Width of one line (canvas units) for the font model in use. */
16
+ export type LineWidth = (line: string) => number;
17
+
18
+ /** Words that read badly at the END of a line ("Hear the / word"). */
19
+ const WEAK_ENDINGS = new Set([
20
+ 'a',
21
+ 'an',
22
+ 'the',
23
+ 'to',
24
+ 'of',
25
+ 'and',
26
+ 'or',
27
+ 'for',
28
+ 'in',
29
+ 'on',
30
+ 'at',
31
+ 'by',
32
+ 'with',
33
+ 'your',
34
+ 'my',
35
+ 'our',
36
+ 'their',
37
+ 'is',
38
+ 'it’s',
39
+ "it's",
40
+ '&'
41
+ ]);
42
+
43
+ const PUNCT_END = /[.,:;!?—–)。,、!?]$/;
44
+
45
+ /**
46
+ * Beyond this many tokens a line is broken greedily (linear) instead of balanced (the balancing DP is
47
+ * O(tokens² · lines)). Real headlines are ≤ ~12 words; this only keeps absurd copy fast — it fails
48
+ * the copy-length rules anyway.
49
+ */
50
+ export const MAX_BALANCED_TOKENS = 40;
51
+
52
+ /** Ideographs, kana, Hangul, fullwidth forms: break anywhere between them; about 1 em wide. */
53
+ export function isWideChar(ch: string): boolean {
54
+ const c = ch.codePointAt(0) ?? 0;
55
+ return (
56
+ (c >= 0x1100 && c <= 0x115f) || // Hangul Jamo
57
+ (c >= 0x2e80 && c <= 0x303e) || // CJK radicals, punctuation
58
+ (c >= 0x3041 && c <= 0x33ff) || // kana, CJK symbols
59
+ (c >= 0x3400 && c <= 0x4dbf) || // CJK ext A
60
+ (c >= 0x4e00 && c <= 0x9fff) || // CJK unified
61
+ (c >= 0xa960 && c <= 0xa97f) ||
62
+ (c >= 0xac00 && c <= 0xd7a3) || // Hangul syllables
63
+ (c >= 0xf900 && c <= 0xfaff) ||
64
+ (c >= 0xfe30 && c <= 0xfe4f) ||
65
+ (c >= 0xff00 && c <= 0xff60) || // fullwidth forms
66
+ (c >= 0xffe0 && c <= 0xffe6) ||
67
+ (c >= 0x20000 && c <= 0x3fffd)
68
+ );
69
+ }
70
+
71
+ interface Token {
72
+ text: string;
73
+ /** A space precedes this token when it follows another on the same line. */
74
+ space: boolean;
75
+ }
76
+
77
+ /** Words split at spaces; runs of wide characters split per character (no space between them). */
78
+ function tokenize(line: string): Token[] {
79
+ const tokens: Token[] = [];
80
+ for (const word of line.split(/\s+/).filter(Boolean)) {
81
+ let buf = '';
82
+ let first = true;
83
+ const flush = () => {
84
+ if (!buf) return;
85
+ tokens.push({ text: buf, space: first });
86
+ first = false;
87
+ buf = '';
88
+ };
89
+ for (const ch of word) {
90
+ if (isWideChar(ch)) {
91
+ flush();
92
+ tokens.push({ text: ch, space: first });
93
+ first = false;
94
+ } else buf += ch;
95
+ }
96
+ flush();
97
+ }
98
+ return tokens;
99
+ }
100
+
101
+ const join = (tokens: Token[], i: number, j: number) => tokens.slice(i, j).reduce((s, t, k) => s + (k > 0 && t.space ? ' ' : '') + t.text, '');
102
+
103
+ /** Greedy wrap at token boundaries — the line COUNT to balance to (and the fallback breaking). */
104
+ function greedy(tokens: Token[], maxWidth: number, width: LineWidth): Array<[number, number]> {
105
+ const lines: Array<[number, number]> = [];
106
+ let start = 0;
107
+ for (let j = 1; j < tokens.length; j++) {
108
+ if (width(join(tokens, start, j + 1)) > maxWidth) {
109
+ lines.push([start, j]);
110
+ start = j;
111
+ }
112
+ }
113
+ if (tokens.length) lines.push([start, tokens.length]);
114
+ return lines;
115
+ }
116
+
117
+ /**
118
+ * Balanced break of one line into its greedy line count (greedy past MAX_BALANCED_TOKENS). A single
119
+ * token wider than the box stays on its own line (the caller reports it — see `overlongWords`).
120
+ */
121
+ function balanceLine(line: string, maxWidth: number, width: LineWidth): string[] {
122
+ const tokens = tokenize(line);
123
+ if (tokens.length === 0) return [''];
124
+ if (width(join(tokens, 0, tokens.length)) <= maxWidth) return [join(tokens, 0, tokens.length)];
125
+ const g = greedy(tokens, maxWidth, width);
126
+ const n = g.length;
127
+ const T = tokens.length;
128
+ if (n === 1 || T > MAX_BALANCED_TOKENS || tokens.some((t) => width(t.text) > maxWidth)) return g.map(([i, j]) => join(tokens, i, j));
129
+
130
+ const cost = (i: number, j: number, last: boolean): number => {
131
+ const w = width(join(tokens, i, j));
132
+ if (w > maxWidth) return Infinity;
133
+ const slack = (maxWidth - w) / maxWidth;
134
+ let c = slack * slack;
135
+ const lastWord = tokens[j - 1].text;
136
+ const words = j - i;
137
+ if (!last) {
138
+ if (PUNCT_END.test(lastWord)) c -= 0.12;
139
+ if (WEAK_ENDINGS.has(lastWord.toLowerCase())) c += 0.35;
140
+ // A lone word on an inner line ("See / their progress") reads as stranded too.
141
+ if (words === 1 && T > 2) c += 0.6;
142
+ } else if (words === 1 && T > 1) {
143
+ // A single word alone on the last line (an orphan) — heavily penalised.
144
+ c += 1.2;
145
+ }
146
+ return c;
147
+ };
148
+ // DP over break positions: best[k][j] = min cost of tokens[0..j) in k lines.
149
+ const best: number[][] = Array.from({ length: n + 1 }, () => new Array(T + 1).fill(Infinity));
150
+ const from: number[][] = Array.from({ length: n + 1 }, () => new Array(T + 1).fill(-1));
151
+ best[0][0] = 0;
152
+ for (let k = 1; k <= n; k++) {
153
+ for (let j = k; j <= T; j++) {
154
+ for (let i = k - 1; i < j; i++) {
155
+ if (best[k - 1][i] === Infinity) continue;
156
+ const c = best[k - 1][i] + cost(i, j, k === n && j === T);
157
+ if (c < best[k][j]) {
158
+ best[k][j] = c;
159
+ from[k][j] = i;
160
+ }
161
+ }
162
+ }
163
+ }
164
+ if (best[n][T] === Infinity) return g.map(([i, j]) => join(tokens, i, j));
165
+ const lines: string[] = [];
166
+ for (let k = n, j = T; k > 0; k--) {
167
+ const i = from[k][j];
168
+ lines.unshift(join(tokens, i, j));
169
+ j = i;
170
+ }
171
+ return lines;
172
+ }
173
+
174
+ /**
175
+ * The lines `text` is set in: every explicit `\n` line is kept as written, and only a line that
176
+ * doesn't fit the box is broken (balanced) — never merged with its neighbours.
177
+ */
178
+ export function breakLines(text: string, maxWidth: number, width: LineWidth): string[] {
179
+ return text
180
+ .replace(/\r/g, '')
181
+ .split('\n')
182
+ .flatMap((line) => balanceLine(line.trim().replace(/\s+/g, ' '), maxWidth, width));
183
+ }
184
+
185
+ /**
186
+ * Tokens (words, or single wide characters) wider than the box. Fabric's Textbox never breaks inside
187
+ * a word — it widens the box to the longest word, which would overflow the side margin — so the
188
+ * composer rejects such copy with a clear error.
189
+ */
190
+ export function overlongWords(text: string, maxWidth: number, width: LineWidth): string[] {
191
+ return tokenize(text.replace(/\n/g, ' ')).filter((t) => width(t.text) > maxWidth).map((t) => t.text);
192
+ }
193
+
194
+ /** True when the LAST line of `lines` is one short word while the block has more than one line. */
195
+ export function hasOrphan(lines: string[]): boolean {
196
+ if (lines.length < 2) return false;
197
+ const last = lines[lines.length - 1].trim().split(/\s+/).filter(Boolean);
198
+ const prev = lines[lines.length - 2].trim().split(/\s+/).filter(Boolean);
199
+ return last.length === 1 && [...last[0]].length > 0 && !isWideChar([...last[0]][0]) && prev.length > 1;
200
+ }
package/src/validate.ts CHANGED
@@ -199,12 +199,38 @@ function checkImageRefs(value: unknown, at: string, errors: string[], depth = 0)
199
199
  }
200
200
  }
201
201
 
202
+ const DEVICE_SHADOW_KEYS = new Set(['color', 'blur', 'offsetX', 'offsetY']);
203
+
204
+ /**
205
+ * Problems with a device layer's `fabricData.deviceShadow` (shot-dsl 0.5.0): `{ color, blur, offsetX,
206
+ * offsetY }` in canvas units — a colour string (≤ 64 chars) and finite numbers, blur in
207
+ * [0, canvasWidth] and offsets within ±canvasWidth (the composer emits ~7.5% / 3% of W).
208
+ */
209
+ export function validateDeviceShadow(value: unknown, canvasWidth: number, at = 'deviceShadow'): string[] {
210
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return [`${at} must be an object`];
211
+ const errors: string[] = [];
212
+ const v = value as Record<string, unknown>;
213
+ for (const key of Object.keys(v)) if (!DEVICE_SHADOW_KEYS.has(key)) errors.push(`${at}.${key} is not allowed`);
214
+ if (typeof v.color !== 'string' || !v.color || v.color.length > 64) errors.push(`${at}.color must be a colour string (≤ 64 chars)`);
215
+ const limit = canvasWidth > 0 && Number.isFinite(canvasWidth) ? canvasWidth : 4096;
216
+ const num = (key: string, min: number) => {
217
+ const n = v[key];
218
+ if (n === undefined && key !== 'blur') return;
219
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < min || n > limit) errors.push(`${at}.${key} must be a number from ${min} to ${limit}`);
220
+ };
221
+ num('blur', 0);
222
+ num('offsetX', -limit);
223
+ num('offsetY', -limit);
224
+ return errors;
225
+ }
226
+
202
227
  /** Per-layer fabricData rules for handoffs (see validateTemplate). */
203
228
  function validateLayerFabricData(
204
229
  layer: LayerJSON,
205
230
  fd: Record<string, unknown>,
206
231
  at: string,
207
- frameIds: Set<string>
232
+ frameIds: Set<string>,
233
+ canvasWidth = 0
208
234
  ): string[] {
209
235
  const errors: string[] = [];
210
236
  const fdAt = `${at}.fabricData`;
@@ -251,6 +277,12 @@ function validateLayerFabricData(
251
277
  if (fd.layerType === 'deviceFrame') errors.push(`${fdAt}.layerType "deviceFrame" is only allowed on device layers`);
252
278
  }
253
279
 
280
+ // Device drop shadow (0.5.0).
281
+ if (fd.deviceShadow !== undefined) {
282
+ if (layer.type !== 'device') errors.push(`${fdAt}.deviceShadow is only allowed on device layers`);
283
+ errors.push(...validateDeviceShadow(fd.deviceShadow, canvasWidth, `${fdAt}.deviceShadow`));
284
+ }
285
+
254
286
  // Device-owned screenshot (0.3.0+).
255
287
  if (fd.screenshot !== undefined) {
256
288
  if (layer.type !== 'device') errors.push(`${fdAt}.screenshot is only allowed on device layers`);
@@ -272,6 +304,18 @@ function validateLayerFabricData(
272
304
  return errors;
273
305
  }
274
306
 
307
+ /**
308
+ * The handoff API's size cap: `POST /api/handoffs` rejects a body over this many bytes (413). The
309
+ * body is `JSON.stringify({ template })`. Producers (the skill's `lint` / `publish`) check
310
+ * `handoffBytes` against it before any network call.
311
+ */
312
+ export const MAX_HANDOFF_BYTES = 256 * 1024;
313
+
314
+ /** UTF-8 size of the handoff request body for `template` (what the server measures). */
315
+ export function handoffBytes(template: unknown): number {
316
+ return new TextEncoder().encode(JSON.stringify({ template })).length;
317
+ }
318
+
275
319
  export interface ValidationResult {
276
320
  valid: boolean;
277
321
  errors: string[];
@@ -315,7 +359,7 @@ export function validateTemplate(data: unknown): ValidationResult {
315
359
 
316
360
  const fd = layer.fabricData as Record<string, unknown> | null;
317
361
  if (!fd || typeof fd !== 'object') return;
318
- errors.push(...validateLayerFabricData(layer, fd, at, frameIds));
362
+ errors.push(...validateLayerFabricData(layer, fd, at, frameIds, s.canvasWidth ?? 0));
319
363
  });
320
364
  });
321
365
  }