@efectoapp/mcp-server 0.1.19 → 0.1.22

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.
@@ -14,9 +14,13 @@
14
14
  */
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.stateTools = void 0;
17
+ exports.ensurePoster = ensurePoster;
17
18
  exports.resetState = resetState;
18
19
  exports.getCurrentState = getCurrentState;
19
20
  exports.handleStateTool = handleStateTool;
21
+ const os_1 = require("os");
22
+ const fs_1 = require("fs");
23
+ const path_1 = require("path");
20
24
  // Helper to clamp values to valid ranges
21
25
  function clamp(value, min, max, fallback) {
22
26
  const v = value ?? fallback;
@@ -24,6 +28,335 @@ function clamp(value, min, max, fallback) {
24
28
  return fallback;
25
29
  return Math.min(max, Math.max(min, v));
26
30
  }
31
+ function parseNumericInput(input) {
32
+ if (typeof input === 'number' && Number.isFinite(input))
33
+ return input;
34
+ if (typeof input === 'string') {
35
+ const trimmed = input.trim();
36
+ if (!trimmed)
37
+ return undefined;
38
+ const stripped = trimmed.endsWith('%') ? trimmed.slice(0, -1) : trimmed;
39
+ const parsed = Number.parseFloat(stripped);
40
+ if (Number.isFinite(parsed))
41
+ return parsed;
42
+ }
43
+ return undefined;
44
+ }
45
+ /**
46
+ * Palette color lookup map — mirrors lib/color-palettes.ts COLOR_PALETTES.
47
+ * When a paletteId is provided, we resolve it to actual hex colors so the
48
+ * colors array is always in sync and URL-encoded state renders correctly.
49
+ */
50
+ const PALETTE_COLORS = {
51
+ // Classic 2-color
52
+ noir: ['#000000', '#ffffff'],
53
+ ink: ['#1a1a2e', '#f5f5dc'],
54
+ terminal: ['#001100', '#00ff00'],
55
+ 'amber-glow': ['#1a0f00', '#ffcc00'],
56
+ // Retro gaming
57
+ gameboy: ['#0f380f', '#306230', '#8bac0f', '#9bbc0f'],
58
+ commodore: ['#000000', '#6c5eb5', '#959595', '#ffffff'],
59
+ nes: ['#000000', '#b53120', '#6b6b6b', '#fcfcfc'],
60
+ cga: ['#000000', '#aa00aa', '#00aaaa', '#ffffff'],
61
+ // Warm
62
+ 'sunset-blvd': ['#1a1423', '#4a1942', '#b33951', '#f5a962', '#fff4e0'],
63
+ campfire: ['#1a0a0a', '#4a0e0e', '#8b1a1a', '#d44d1a', '#ffcc00'],
64
+ 'autumn-leaves': ['#2d1b0e', '#6b3e26', '#b5651d', '#e09f3e', '#f5deb3'],
65
+ terracotta: ['#1f1410', '#5c3d2e', '#b36b4f', '#e8a87c', '#faf0e6'],
66
+ 'golden-hour': ['#1a1205', '#4a3510', '#8b6914', '#d4a017', '#fff8dc'],
67
+ 'rose-gold': ['#2a1a1a', '#6b4040', '#b76e79', '#e8b4bc', '#fff0f5'],
68
+ // Cool
69
+ 'deep-sea': ['#0a1628', '#1a3a5c', '#2d6187', '#5ba4c9', '#a8dce8'],
70
+ 'arctic-night': ['#0a0a14', '#1a2a4a', '#3a5a8a', '#6a9aca', '#cae8ff'],
71
+ moonlight: ['#0f0f1a', '#2a2a4a', '#4a4a7a', '#8a8ab0', '#e0e0f0'],
72
+ frozen: ['#1a1a2e', '#16213e', '#0f3460', '#53a8b6', '#e8f1f5'],
73
+ twilight: ['#0d0221', '#261447', '#4a2c6a', '#7a4a9a', '#c8a8e8'],
74
+ // Neon
75
+ synthwave: ['#120458', '#7b2cbf', '#e040fb', '#ff6ec7', '#fff59d'],
76
+ vaporwave: ['#1a0a2e', '#3d1a5c', '#ff71ce', '#01cdfe', '#fffb96'],
77
+ cyberpunk: ['#0d0221', '#261447', '#6b2d5c', '#f72585', '#4cc9f0'],
78
+ 'tokyo-night': ['#1a1b26', '#414868', '#7aa2f7', '#bb9af7', '#c0caf5'],
79
+ arcade: ['#0d0d0d', '#ff073a', '#39ff14', '#00f0ff', '#ffffff'],
80
+ retrowave: ['#2b1055', '#7f00ff', '#e100ff', '#ff5c8d', '#ffd700'],
81
+ electric: ['#0a0a0a', '#0066ff', '#00ffff', '#ff00ff', '#ffffff'],
82
+ // Earth
83
+ forest: ['#1a2e1a', '#2d4a2d', '#4a7c4a', '#7ab37a', '#c8e6c8'],
84
+ moss: ['#1a1f14', '#3d4a2d', '#6b7b4a', '#a8b88a', '#d4e4bc'],
85
+ desert: ['#2e1f14', '#6b4423', '#c19a6b', '#e6c89c', '#f5e6d3'],
86
+ clay: ['#2a1810', '#5a3828', '#9a6848', '#d4a878', '#f4e4d4'],
87
+ 'ocean-floor': ['#0a1a1a', '#1a3a3a', '#2a6a5a', '#4a9a7a', '#8adaaa'],
88
+ // Mono
89
+ grayscale: ['#000000', '#404040', '#808080', '#c0c0c0', '#ffffff'],
90
+ sepia: ['#1a1610', '#3d3020', '#6b5a40', '#a89070', '#e8dcc8'],
91
+ blueprint: ['#001830', '#003060', '#0050a0', '#0080e0', '#e0f0ff'],
92
+ cyanotype: ['#001a2e', '#003050', '#005080', '#0080b0', '#a0d8f0'],
93
+ 'green-phosphor': ['#001400', '#003300', '#006600', '#00cc00', '#66ff66'],
94
+ 'amber-phosphor': ['#1a0f00', '#3d2400', '#7a4800', '#cc7a00', '#ffcc66'],
95
+ };
96
+ /** Resolve a paletteId to its colors, falling back to the provided default. */
97
+ function resolvePaletteColors(paletteId, fallback) {
98
+ if (!paletteId)
99
+ return fallback;
100
+ return PALETTE_COLORS[paletteId] ?? fallback;
101
+ }
102
+ const TEXT_WORLD_SCALE_FACTOR = 0.15 / 72;
103
+ function getArtboardAspect(aspectRatio) {
104
+ switch (aspectRatio) {
105
+ case '16:9':
106
+ return 16 / 9;
107
+ case '9:16':
108
+ return 9 / 16;
109
+ case '1:1':
110
+ return 1;
111
+ case '4:3':
112
+ return 4 / 3;
113
+ case '3:4':
114
+ return 3 / 4;
115
+ default:
116
+ return 9 / 16;
117
+ }
118
+ }
119
+ function parseNormalizedFrameDimension(input) {
120
+ const value = parseNumericInput(input);
121
+ if (value === undefined)
122
+ return undefined;
123
+ if (!Number.isFinite(value))
124
+ return undefined;
125
+ return clamp(value, 0.01, 2, value);
126
+ }
127
+ function textNormalizedToPx(normalized, artboardAspect, axis) {
128
+ if (axis === 'height') {
129
+ return normalized / TEXT_WORLD_SCALE_FACTOR;
130
+ }
131
+ return (normalized * artboardAspect) / TEXT_WORLD_SCALE_FACTOR;
132
+ }
133
+ /** Convert CSS-style % from left (0=left, 50=center, 100=right) to normalized x (-1..1). */
134
+ function cssPercentToX(input, fallbackPct = 50) {
135
+ const value = parseNumericInput(input);
136
+ if (value === undefined)
137
+ return (fallbackPct / 50) - 1;
138
+ // Always interpret as CSS percentage from left edge
139
+ const pct = Math.max(-50, Math.min(150, value));
140
+ return (pct / 50) - 1; // 0→-1, 50→0, 100→1
141
+ }
142
+ /** Convert CSS-style % from top (0=top, 50=center, 100=bottom) to normalized y (-1..1, +up). */
143
+ function cssPercentToY(input, fallbackPct = 50) {
144
+ const value = parseNumericInput(input);
145
+ if (value === undefined)
146
+ return 1 - (fallbackPct / 50);
147
+ // Always interpret as CSS percentage from top edge
148
+ const pct = Math.max(-50, Math.min(150, value));
149
+ return 1 - (pct / 50); // 0→1(top), 50→0(center), 100→-1(bottom)
150
+ }
151
+ /** Convert CSS-style % size (60=60%) to normalized size (0.6).
152
+ * Values 0-1 pass through for backward compat. */
153
+ function cssPercentToSize(input, fallbackPct = 100) {
154
+ const value = parseNumericInput(input);
155
+ if (value === undefined)
156
+ return fallbackPct / 100;
157
+ // Values > 1 are treated as %, values 0-1 pass through
158
+ const normalized = value > 1 && value <= 200 ? value / 100 : value;
159
+ return clamp(normalized, 0.01, 4, normalized);
160
+ }
161
+ function normalizeOpacity(input, fallback) {
162
+ const value = parseNumericInput(input);
163
+ if (value === undefined)
164
+ return fallback;
165
+ const normalized = value > 1 && value <= 100 ? value / 100 : value;
166
+ return clamp(normalized, 0, 1, fallback);
167
+ }
168
+ function normalizeStrokeWidth(input, fallback) {
169
+ const value = parseNumericInput(input);
170
+ if (value === undefined)
171
+ return fallback;
172
+ const normalized = value > 1 ? value / 1000 : value;
173
+ return clamp(normalized, 0, 0.2, fallback);
174
+ }
175
+ function normalizeCornerRadius(input, fallback) {
176
+ const value = parseNumericInput(input);
177
+ if (value === undefined)
178
+ return fallback;
179
+ const normalized = value > 1 ? value / 1000 : value;
180
+ return clamp(normalized, 0, 1, fallback);
181
+ }
182
+ function normalizePositiveInt(input, fallback, min, max) {
183
+ const value = parseNumericInput(input);
184
+ if (value === undefined)
185
+ return fallback;
186
+ const rounded = Math.round(value);
187
+ return Math.min(max, Math.max(min, rounded));
188
+ }
189
+ function parseFlexChildProps(input) {
190
+ const result = {};
191
+ if (typeof input.positioning === 'string') {
192
+ const v = input.positioning.trim().toLowerCase();
193
+ if (v === 'flow' || v === 'absolute')
194
+ result.positioning = v;
195
+ }
196
+ // Per-axis sizing (preferred)
197
+ if (typeof input.flexWidthMode === 'string') {
198
+ const v = input.flexWidthMode.trim().toLowerCase();
199
+ if (v === 'hug' || v === 'fixed' || v === 'fill')
200
+ result.flexWidthMode = v;
201
+ }
202
+ if (typeof input.flexHeightMode === 'string') {
203
+ const v = input.flexHeightMode.trim().toLowerCase();
204
+ if (v === 'hug' || v === 'fixed' || v === 'fill')
205
+ result.flexHeightMode = v;
206
+ }
207
+ // Legacy single-axis: applies to both axes if per-axis not set
208
+ if (typeof input.flexSizeMode === 'string') {
209
+ const v = input.flexSizeMode.trim().toLowerCase();
210
+ if (v === 'hug' || v === 'fixed' || v === 'fill') {
211
+ if (!result.flexWidthMode)
212
+ result.flexWidthMode = v;
213
+ if (!result.flexHeightMode)
214
+ result.flexHeightMode = v;
215
+ }
216
+ }
217
+ if (typeof input.alignSelf === 'string') {
218
+ const v = input.alignSelf.trim().toLowerCase();
219
+ if (v === 'auto' || v === 'start' || v === 'center' || v === 'end' || v === 'stretch')
220
+ result.alignSelf = v;
221
+ }
222
+ if (input.flexGrow !== undefined) {
223
+ const val = Number(input.flexGrow);
224
+ if (Number.isFinite(val) && val >= 0)
225
+ result.flexGrow = val;
226
+ }
227
+ return result;
228
+ }
229
+ // --- Visual property parsers (shadow, blendMode, textTransform, textDecoration) ---
230
+ const VALID_TEXT_TRANSFORMS = ['none', 'uppercase', 'lowercase', 'capitalize'];
231
+ const VALID_TEXT_DECORATIONS = ['none', 'underline', 'line-through', 'overline'];
232
+ const VALID_BLEND_MODES = ['normal', 'multiply', 'screen', 'darken', 'lighten', 'difference'];
233
+ function parseShadowParams(params) {
234
+ const hasShadow = params.shadowOffsetX !== undefined || params.shadowOffsetY !== undefined ||
235
+ params.shadowBlur !== undefined || params.shadowColor !== undefined;
236
+ if (!hasShadow)
237
+ return undefined;
238
+ return {
239
+ offsetX: typeof params.shadowOffsetX === 'number' ? params.shadowOffsetX : 0,
240
+ offsetY: typeof params.shadowOffsetY === 'number' ? params.shadowOffsetY : 4,
241
+ blur: typeof params.shadowBlur === 'number' ? Math.max(0, params.shadowBlur) : 8,
242
+ color: normalizeHexColor(params.shadowColor) || '#000000',
243
+ opacity: typeof params.shadowOpacity === 'number' ? Math.max(0, Math.min(1, params.shadowOpacity)) : 0.5,
244
+ };
245
+ }
246
+ function parseBlendMode(params) {
247
+ const mode = params.blendMode;
248
+ if (!mode || !VALID_BLEND_MODES.includes(mode))
249
+ return undefined;
250
+ if (mode === 'normal')
251
+ return undefined; // Don't store default
252
+ return mode;
253
+ }
254
+ function parseTextTransform(params) {
255
+ const val = params.textTransform;
256
+ if (!val || !VALID_TEXT_TRANSFORMS.includes(val))
257
+ return undefined;
258
+ if (val === 'none')
259
+ return undefined;
260
+ return val;
261
+ }
262
+ function parseTextDecoration(params) {
263
+ const result = {};
264
+ const val = params.textDecoration;
265
+ if (val && VALID_TEXT_DECORATIONS.includes(val) && val !== 'none') {
266
+ result.textDecoration = val;
267
+ }
268
+ if (params.textDecorationColor !== undefined) {
269
+ const color = normalizeHexColor(params.textDecorationColor);
270
+ if (color)
271
+ result.textDecorationColor = color;
272
+ }
273
+ if (typeof params.textDecorationThickness === 'number' && params.textDecorationThickness > 0) {
274
+ result.textDecorationThickness = params.textDecorationThickness;
275
+ }
276
+ return result;
277
+ }
278
+ /** Parse margin params */
279
+ function parseMarginParams(params) {
280
+ const hasMargin = params.marginTop !== undefined || params.marginRight !== undefined ||
281
+ params.marginBottom !== undefined || params.marginLeft !== undefined;
282
+ if (!hasMargin)
283
+ return undefined;
284
+ return {
285
+ top: typeof params.marginTop === 'number' ? params.marginTop : 0,
286
+ right: typeof params.marginRight === 'number' ? params.marginRight : 0,
287
+ bottom: typeof params.marginBottom === 'number' ? params.marginBottom : 0,
288
+ left: typeof params.marginLeft === 'number' ? params.marginLeft : 0,
289
+ };
290
+ }
291
+ /** Parse min/max size constraint params */
292
+ function parseMinMaxConstraints(params) {
293
+ const result = {};
294
+ if (typeof params.minWidth === 'number')
295
+ result.minWidth = Math.max(0, Math.min(1, params.minWidth));
296
+ if (typeof params.maxWidth === 'number')
297
+ result.maxWidth = Math.max(0, Math.min(1, params.maxWidth));
298
+ if (typeof params.minHeight === 'number')
299
+ result.minHeight = Math.max(0, Math.min(1, params.minHeight));
300
+ if (typeof params.maxHeight === 'number')
301
+ result.maxHeight = Math.max(0, Math.min(1, params.maxHeight));
302
+ return result;
303
+ }
304
+ /** Parse per-corner radius params */
305
+ function parseCornerRadii(params) {
306
+ const hasPerCorner = params.cornerRadiusTopLeft !== undefined || params.cornerRadiusTopRight !== undefined ||
307
+ params.cornerRadiusBottomRight !== undefined || params.cornerRadiusBottomLeft !== undefined;
308
+ if (!hasPerCorner)
309
+ return undefined;
310
+ return {
311
+ topLeft: typeof params.cornerRadiusTopLeft === 'number' ? Math.max(0, params.cornerRadiusTopLeft) : 0,
312
+ topRight: typeof params.cornerRadiusTopRight === 'number' ? Math.max(0, params.cornerRadiusTopRight) : 0,
313
+ bottomRight: typeof params.cornerRadiusBottomRight === 'number' ? Math.max(0, params.cornerRadiusBottomRight) : 0,
314
+ bottomLeft: typeof params.cornerRadiusBottomLeft === 'number' ? Math.max(0, params.cornerRadiusBottomLeft) : 0,
315
+ };
316
+ }
317
+ /** Parse transform origin params */
318
+ function parseTransformOrigin(params) {
319
+ const result = {};
320
+ if (typeof params.originX === 'number')
321
+ result.originX = Math.max(0, Math.min(1, params.originX));
322
+ if (typeof params.originY === 'number')
323
+ result.originY = Math.max(0, Math.min(1, params.originY));
324
+ return result;
325
+ }
326
+ /** Parse visual properties shared across all layer types (shadow, blendMode, margin, min/max constraints) */
327
+ function parseVisualOverrides(params) {
328
+ const result = {};
329
+ const shadow = parseShadowParams(params);
330
+ if (shadow)
331
+ result.shadow = shadow;
332
+ const blendMode = parseBlendMode(params);
333
+ if (blendMode)
334
+ result.blendMode = blendMode;
335
+ const margin = parseMarginParams(params);
336
+ if (margin)
337
+ result.margin = margin;
338
+ const constraints = parseMinMaxConstraints(params);
339
+ Object.assign(result, constraints);
340
+ return result;
341
+ }
342
+ function normalizeHexColor(value) {
343
+ if (typeof value !== 'string')
344
+ return null;
345
+ const trimmed = value.trim();
346
+ if (!trimmed)
347
+ return null;
348
+ const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`;
349
+ if (/^#[0-9a-f]{6}$/i.test(withHash))
350
+ return withHash.toLowerCase();
351
+ if (/^#[0-9a-f]{3}$/i.test(withHash)) {
352
+ const [, r, g, b] = withHash;
353
+ return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
354
+ }
355
+ return null;
356
+ }
357
+ function asFiniteNumber(value, fallback) {
358
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
359
+ }
27
360
  // Known fonts with their proper CSS values
28
361
  const KNOWN_FONTS = {
29
362
  'DM Sans': '"DM Sans", sans-serif',
@@ -66,9 +399,75 @@ function normalizeFontFamily(input) {
66
399
  }
67
400
  // Current poster state (single poster for now)
68
401
  let currentPoster = null;
402
+ // --- File-based state persistence ---
403
+ // Some MCP clients spawn a fresh process per tool call, losing in-memory state.
404
+ // We persist to a temp file so state survives process restarts.
405
+ const STATE_FILE_PATH = (0, path_1.join)((0, os_1.tmpdir)(), 'efecto-mcp-state.json');
406
+ const STATE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
407
+ /** Write current poster state to the temp file (best-effort). */
408
+ function persistState() {
409
+ if (!currentPoster)
410
+ return;
411
+ try {
412
+ const payload = JSON.stringify({ timestamp: Date.now(), state: currentPoster });
413
+ (0, fs_1.writeFileSync)(STATE_FILE_PATH, payload, 'utf-8');
414
+ }
415
+ catch {
416
+ // Silently ignore — falls back to in-memory only
417
+ }
418
+ }
419
+ /** Load persisted state from the temp file if valid and not stale. */
420
+ function loadPersistedState() {
421
+ try {
422
+ const raw = (0, fs_1.readFileSync)(STATE_FILE_PATH, 'utf-8');
423
+ const parsed = JSON.parse(raw);
424
+ if (!parsed.state || !parsed.timestamp)
425
+ return null;
426
+ if (Date.now() - parsed.timestamp > STATE_MAX_AGE_MS)
427
+ return null;
428
+ return parsed.state;
429
+ }
430
+ catch {
431
+ return null;
432
+ }
433
+ }
434
+ /**
435
+ * Ensure a poster exists. Restores from persisted file if needed,
436
+ * or auto-creates a default poster as a last resort.
437
+ */
438
+ function ensurePoster() {
439
+ if (currentPoster)
440
+ return currentPoster;
441
+ // Try restoring from persisted state
442
+ const persisted = loadPersistedState();
443
+ if (persisted) {
444
+ currentPoster = persisted;
445
+ return currentPoster;
446
+ }
447
+ // Auto-create a default poster (same defaults as create_poster)
448
+ currentPoster = {
449
+ canvas: {
450
+ aspectRatio: '9:16',
451
+ backgroundColor: '#1a1a1a',
452
+ layoutMode: 'column',
453
+ alignItems: 'center',
454
+ justifyContent: 'start',
455
+ gap: 0.04,
456
+ padding: { top: 0.08, right: 0.08, bottom: 0.08, left: 0.08 },
457
+ },
458
+ layers: [createDefaultBackgroundLayer('#1a1a1a')],
459
+ postProcesses: [],
460
+ };
461
+ persistState();
462
+ return currentPoster;
463
+ }
69
464
  // Reset state (useful for testing)
70
465
  function resetState() {
71
466
  currentPoster = null;
467
+ try {
468
+ (0, fs_1.unlinkSync)(STATE_FILE_PATH);
469
+ }
470
+ catch { /* ignore */ }
72
471
  }
73
472
  // Get current state
74
473
  function getCurrentState() {
@@ -85,7 +484,7 @@ function getDefaultAnimationParams(type) {
85
484
  const base = {
86
485
  type,
87
486
  enabled: true,
88
- speed: 1.0,
487
+ speed: 0.2,
89
488
  amplitude: 50,
90
489
  staggerDelay: 150,
91
490
  staggerMode: 'sequential',
@@ -289,8 +688,225 @@ function createDefaultBackgroundLayer(backgroundColor) {
289
688
  },
290
689
  contentType: 'solid',
291
690
  solidColor: backgroundColor,
691
+ fill: {
692
+ type: 'solid',
693
+ color: backgroundColor,
694
+ opacity: 1,
695
+ },
696
+ };
697
+ }
698
+ const DEFAULT_LINEAR_GRADIENT_FILL = {
699
+ type: 'linear',
700
+ angle: 135,
701
+ stops: [
702
+ { color: '#7c3aed', opacity: 1, position: 0 },
703
+ { color: '#22d3ee', opacity: 1, position: 1 },
704
+ ],
705
+ };
706
+ const DEFAULT_RADIAL_GRADIENT_FILL = {
707
+ type: 'radial',
708
+ center: { x: 0.5, y: 0.5 },
709
+ radius: 0.8,
710
+ stops: [
711
+ { color: '#a78bfa', opacity: 1, position: 0 },
712
+ { color: '#0f172a', opacity: 1, position: 1 },
713
+ ],
714
+ };
715
+ const MIN_GRADIENT_STOPS = 2;
716
+ const MAX_GRADIENT_STOPS = 8;
717
+ function clamp01(value) {
718
+ if (!Number.isFinite(value))
719
+ return 0;
720
+ return Math.min(1, Math.max(0, value));
721
+ }
722
+ function normalizeAngle(value) {
723
+ if (!Number.isFinite(value))
724
+ return 0;
725
+ const normalized = value % 360;
726
+ return normalized < 0 ? normalized + 360 : normalized;
727
+ }
728
+ function clampRadius(value) {
729
+ if (!Number.isFinite(value))
730
+ return DEFAULT_RADIAL_GRADIENT_FILL.radius;
731
+ return Math.min(2, Math.max(0.05, value));
732
+ }
733
+ function isHexColor(value) {
734
+ return /^#[0-9a-fA-F]{3}$/.test(value) || /^#[0-9a-fA-F]{6}$/.test(value);
735
+ }
736
+ function normalizeGradientStop(stop, fallback) {
737
+ return {
738
+ color: isHexColor(stop.color) ? stop.color : fallback.color,
739
+ opacity: clamp01(stop.opacity),
740
+ position: clamp01(stop.position),
741
+ };
742
+ }
743
+ function normalizeGradientStops(stops, fallbackStops) {
744
+ const fallbackStart = fallbackStops[0];
745
+ const fallbackEnd = fallbackStops[fallbackStops.length - 1] ?? fallbackStart;
746
+ const input = Array.isArray(stops) ? stops : [];
747
+ const normalized = input
748
+ .map((stop, index) => normalizeGradientStop(stop, fallbackStops[Math.min(index, fallbackStops.length - 1)] ?? fallbackEnd))
749
+ .slice(0, MAX_GRADIENT_STOPS);
750
+ if (normalized.length < MIN_GRADIENT_STOPS) {
751
+ if (normalized.length === 0) {
752
+ normalized.push(normalizeGradientStop(fallbackStart, fallbackStart), normalizeGradientStop(fallbackEnd, fallbackEnd));
753
+ }
754
+ else if (normalized.length === 1) {
755
+ normalized.push(normalizeGradientStop(fallbackEnd, fallbackEnd));
756
+ }
757
+ }
758
+ return normalized
759
+ .map((stop, index) => ({ stop, index }))
760
+ .sort((a, b) => a.stop.position - b.stop.position || a.index - b.index)
761
+ .map(({ stop }) => stop);
762
+ }
763
+ function normalizeLinearGradientFill(fill) {
764
+ const fallback = DEFAULT_LINEAR_GRADIENT_FILL;
765
+ return {
766
+ type: 'linear',
767
+ angle: normalizeAngle(fill.angle),
768
+ stops: normalizeGradientStops(fill.stops, fallback.stops),
769
+ };
770
+ }
771
+ function normalizeRadialGradientFill(fill) {
772
+ const fallback = DEFAULT_RADIAL_GRADIENT_FILL;
773
+ return {
774
+ type: 'radial',
775
+ center: {
776
+ x: clamp01(fill.center?.x ?? fallback.center.x),
777
+ y: clamp01(fill.center?.y ?? fallback.center.y),
778
+ },
779
+ radius: clampRadius(fill.radius),
780
+ stops: normalizeGradientStops(fill.stops, fallback.stops),
292
781
  };
293
782
  }
783
+ function parseGradientStopsInput(input, fallbackStops) {
784
+ if (!Array.isArray(input))
785
+ return null;
786
+ const parsed = input
787
+ .map((item, index) => {
788
+ if (!item || typeof item !== 'object')
789
+ return null;
790
+ const candidate = item;
791
+ const fallback = fallbackStops[Math.min(index, fallbackStops.length - 1)] ?? fallbackStops[0];
792
+ const opacityRaw = typeof candidate.opacity === 'number' ? candidate.opacity : Number.parseFloat(String(candidate.opacity ?? ''));
793
+ const positionRaw = typeof candidate.position === 'number' ? candidate.position : Number.parseFloat(String(candidate.position ?? ''));
794
+ return {
795
+ color: typeof candidate.color === 'string' && isHexColor(candidate.color) ? candidate.color : fallback.color,
796
+ opacity: clamp01(Number.isFinite(opacityRaw) ? opacityRaw : fallback.opacity),
797
+ position: clamp01(Number.isFinite(positionRaw) ? positionRaw : fallback.position),
798
+ };
799
+ })
800
+ .filter((stop) => stop !== null);
801
+ return parsed.length >= MIN_GRADIENT_STOPS ? parsed : null;
802
+ }
803
+ /**
804
+ * Build a Fill object from flat MCP tool params (fillType, gradientStops, etc.).
805
+ * Returns null when no gradient params are present (caller should fall back to solid).
806
+ */
807
+ function buildFillFromParams(params) {
808
+ const fillType = params.fillType;
809
+ const hasGradientParams = fillType === 'linear' || fillType === 'radial' ||
810
+ params.gradientStyle !== undefined ||
811
+ params.gradientStops !== undefined ||
812
+ params.gradientStartColor !== undefined ||
813
+ params.gradientEndColor !== undefined;
814
+ if (!hasGradientParams)
815
+ return null;
816
+ const gradientStyle = fillType === 'radial' || params.gradientStyle === 'radial' ? 'radial' : 'linear';
817
+ const defaults = gradientStyle === 'radial'
818
+ ? DEFAULT_RADIAL_GRADIENT_FILL
819
+ : DEFAULT_LINEAR_GRADIENT_FILL;
820
+ const explicitStops = parseGradientStopsInput(params.gradientStops, defaults.stops);
821
+ const stops = explicitStops ?? [
822
+ {
823
+ color: params.gradientStartColor || defaults.stops[0].color,
824
+ opacity: defaults.stops[0].opacity,
825
+ position: defaults.stops[0].position,
826
+ },
827
+ {
828
+ color: params.gradientEndColor || defaults.stops[1].color,
829
+ opacity: defaults.stops[1].opacity,
830
+ position: defaults.stops[1].position,
831
+ },
832
+ ];
833
+ if (gradientStyle === 'radial') {
834
+ return normalizeRadialGradientFill({
835
+ type: 'radial',
836
+ center: {
837
+ x: Number.isFinite(params.gradientCenterX)
838
+ ? params.gradientCenterX
839
+ : DEFAULT_RADIAL_GRADIENT_FILL.center.x,
840
+ y: Number.isFinite(params.gradientCenterY)
841
+ ? params.gradientCenterY
842
+ : DEFAULT_RADIAL_GRADIENT_FILL.center.y,
843
+ },
844
+ radius: Number.isFinite(params.gradientRadius)
845
+ ? params.gradientRadius
846
+ : DEFAULT_RADIAL_GRADIENT_FILL.radius,
847
+ stops,
848
+ });
849
+ }
850
+ return normalizeLinearGradientFill({
851
+ type: 'linear',
852
+ angle: Number.isFinite(params.gradientAngle)
853
+ ? params.gradientAngle
854
+ : DEFAULT_LINEAR_GRADIENT_FILL.angle,
855
+ stops,
856
+ });
857
+ }
858
+ /** Reusable schema properties for gradient fill params — spread into tool schemas. */
859
+ const GRADIENT_FILL_SCHEMA_PROPS = {
860
+ fillType: {
861
+ type: 'string',
862
+ enum: ['solid', 'linear', 'radial'],
863
+ description: 'Fill type: "solid" (default), "linear" gradient, or "radial" gradient',
864
+ },
865
+ gradientStops: {
866
+ type: 'array',
867
+ description: 'Gradient color stops (2-8). Overrides start/end colors when provided.',
868
+ items: {
869
+ type: 'object',
870
+ properties: {
871
+ color: { type: 'string', description: 'Stop color in hex' },
872
+ opacity: { type: 'number', description: 'Stop opacity (0-1)' },
873
+ position: { type: 'number', description: 'Stop position (0-1)' },
874
+ },
875
+ required: ['color', 'position'],
876
+ },
877
+ minItems: 2,
878
+ maxItems: 8,
879
+ },
880
+ gradientAngle: {
881
+ type: 'number',
882
+ description: 'Linear gradient angle in degrees (default 135)',
883
+ },
884
+ gradientStartColor: {
885
+ type: 'string',
886
+ description: 'Gradient start color in hex (shortcut for 2-stop gradient)',
887
+ },
888
+ gradientEndColor: {
889
+ type: 'string',
890
+ description: 'Gradient end color in hex (shortcut for 2-stop gradient)',
891
+ },
892
+ gradientStyle: {
893
+ type: 'string',
894
+ enum: ['linear', 'radial'],
895
+ description: 'Gradient style (alternative to fillType for setting gradient type)',
896
+ },
897
+ gradientCenterX: {
898
+ type: 'number',
899
+ description: 'Radial gradient center X (0-1)',
900
+ },
901
+ gradientCenterY: {
902
+ type: 'number',
903
+ description: 'Radial gradient center Y (0-1)',
904
+ },
905
+ gradientRadius: {
906
+ type: 'number',
907
+ description: 'Radial gradient radius (0.05-2)',
908
+ },
909
+ };
294
910
  // Tool definitions
295
911
  exports.stateTools = [
296
912
  {
@@ -301,7 +917,7 @@ exports.stateTools = [
301
917
  properties: {
302
918
  aspectRatio: {
303
919
  type: 'string',
304
- enum: ['16:9', '9:16', '1:1', '4:3', 'full'],
920
+ enum: ['16:9', '9:16', '1:1', '4:3', '3:4', 'full'],
305
921
  description: 'Canvas aspect ratio (default: 9:16 for portrait poster)',
306
922
  default: '9:16',
307
923
  },
@@ -310,19 +926,97 @@ exports.stateTools = [
310
926
  description: 'Background color in hex format (e.g., "#1a1a1a")',
311
927
  default: '#1a1a1a',
312
928
  },
929
+ canvasColor: {
930
+ type: 'string',
931
+ description: 'Viewport background color in hex (e.g., "#0a0a0a")',
932
+ },
933
+ overflow: {
934
+ type: 'string',
935
+ enum: ['visible', 'hidden', 'translucent'],
936
+ description: 'Canvas overflow mode',
937
+ },
938
+ },
939
+ required: [],
940
+ },
941
+ },
942
+ {
943
+ name: 'clear_poster',
944
+ description: 'Clear all content layers from the poster, keeping only the background. Resets effect and post-processes.',
945
+ inputSchema: {
946
+ type: 'object',
947
+ properties: {},
948
+ required: [],
949
+ },
950
+ },
951
+ {
952
+ name: 'set_page_layout',
953
+ description: 'Set page/artboard layout mode. Use layoutMode="absolute" for Free placement. Use layoutMode="row" or "column" for Flex layout of top-level layers inside the artboard.',
954
+ inputSchema: {
955
+ type: 'object',
956
+ properties: {
957
+ layoutMode: {
958
+ type: 'string',
959
+ enum: ['absolute', 'row', 'column'],
960
+ description: 'Page layout mode: "absolute" (Free) or flex direction ("row"/"column")',
961
+ },
962
+ justifyContent: {
963
+ type: 'string',
964
+ enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'],
965
+ description: 'Main axis alignment (flex only)',
966
+ },
967
+ alignItems: {
968
+ type: 'string',
969
+ enum: ['start', 'center', 'end'],
970
+ description: 'Cross axis alignment (flex only)',
971
+ },
972
+ wrap: { type: 'boolean', description: 'Wrap to next row (row mode only)', default: false },
973
+ gap: { type: 'number', description: 'Space between children as % of canvas (2=small, 4=medium, 6=large)', default: 0 },
974
+ padding: { type: 'number', description: 'Padding as % of canvas', default: 0 },
975
+ paddingTop: { type: 'number', description: 'Padding top as % of canvas' },
976
+ paddingRight: { type: 'number', description: 'Padding right as % of canvas' },
977
+ paddingBottom: { type: 'number', description: 'Padding bottom as % of canvas' },
978
+ paddingLeft: { type: 'number', description: 'Padding left as % of canvas' },
979
+ },
980
+ required: [],
981
+ },
982
+ },
983
+ {
984
+ name: 'modify_canvas',
985
+ description: 'Modify the canvas/artboard settings after creation. Change aspect ratio, background color, viewport color, or overflow mode.',
986
+ inputSchema: {
987
+ type: 'object',
988
+ properties: {
989
+ aspectRatio: {
990
+ type: 'string',
991
+ enum: ['16:9', '9:16', '1:1', '4:3', '3:4', 'full'],
992
+ description: 'Canvas aspect ratio',
993
+ },
994
+ backgroundColor: {
995
+ type: 'string',
996
+ description: 'Artboard background color in hex (e.g., "#1a1a1a")',
997
+ },
998
+ canvasColor: {
999
+ type: 'string',
1000
+ description: 'Viewport background color in hex (area outside artboard)',
1001
+ },
1002
+ overflow: {
1003
+ type: 'string',
1004
+ enum: ['visible', 'hidden', 'translucent'],
1005
+ description: 'Canvas overflow mode',
1006
+ },
313
1007
  },
314
1008
  required: [],
315
1009
  },
316
1010
  },
317
1011
  {
318
1012
  name: 'set_background',
319
- description: 'Configure the background layer (always at index 0). Can be solid color, image, or video.',
1013
+ description: 'Configure the background layer (always at index 0). Can be solid fill, linear/radial gradient fill, image, or video.',
320
1014
  inputSchema: {
321
1015
  type: 'object',
322
1016
  properties: {
323
1017
  type: {
324
1018
  type: 'string',
325
- enum: ['solid', 'image', 'video'],
1019
+ enum: ['solid', 'gradient', 'image', 'video'],
326
1020
  description: 'Background content type',
327
1021
  default: 'solid',
328
1022
  },
@@ -330,6 +1024,50 @@ exports.stateTools = [
330
1024
  type: 'string',
331
1025
  description: 'Solid background color in hex (for type: solid)',
332
1026
  },
1027
+ gradientStartColor: {
1028
+ type: 'string',
1029
+ description: 'Gradient start color in hex (for type: gradient)',
1030
+ },
1031
+ gradientEndColor: {
1032
+ type: 'string',
1033
+ description: 'Gradient end color in hex (for type: gradient)',
1034
+ },
1035
+ gradientStops: {
1036
+ type: 'array',
1037
+ description: 'Optional gradient stops array (2-8 items). If provided, overrides start/end colors.',
1038
+ items: {
1039
+ type: 'object',
1040
+ properties: {
1041
+ color: { type: 'string', description: 'Stop color in hex' },
1042
+ opacity: { type: 'number', description: 'Stop opacity (0-1)' },
1043
+ position: { type: 'number', description: 'Stop position (0-1)' },
1044
+ },
1045
+ required: ['color', 'position'],
1046
+ },
1047
+ minItems: 2,
1048
+ maxItems: 8,
1049
+ },
1050
+ gradientStyle: {
1051
+ type: 'string',
1052
+ enum: ['linear', 'radial'],
1053
+ description: 'Gradient style (for type: gradient)',
1054
+ },
1055
+ gradientAngle: {
1056
+ type: 'number',
1057
+ description: 'Linear gradient angle in degrees (for linear style)',
1058
+ },
1059
+ gradientCenterX: {
1060
+ type: 'number',
1061
+ description: 'Radial center X (0-1, for radial style)',
1062
+ },
1063
+ gradientCenterY: {
1064
+ type: 'number',
1065
+ description: 'Radial center Y (0-1, for radial style)',
1066
+ },
1067
+ gradientRadius: {
1068
+ type: 'number',
1069
+ description: 'Radial radius scale (for radial style)',
1070
+ },
333
1071
  imageUrl: {
334
1072
  type: 'string',
335
1073
  description: 'Image URL (for type: image)',
@@ -356,26 +1094,66 @@ exports.stateTools = [
356
1094
  properties: {
357
1095
  type: {
358
1096
  type: 'string',
359
- enum: ['text', 'image', 'video'],
1097
+ enum: ['text', 'image', 'video', 'rectangle', 'ellipse', 'polygon', 'star', 'line'],
360
1098
  description: 'Type of layer to add',
361
1099
  },
362
1100
  name: { type: 'string', description: 'Layer name' },
363
1101
  // Transform properties (normalized coordinates relative to canvas)
364
- x: { type: 'number', description: 'X position (-1 to 1, 0 is center, positive is right)', default: 0 },
365
- y: { type: 'number', description: 'Y position (-1 to 1, 0 is center, POSITIVE is UP/TOP, negative is down/bottom)', default: 0 },
366
- width: { type: 'number', description: 'Width (0-1 relative to canvas)', default: 1 },
367
- height: { type: 'number', description: 'Height (0-1 relative to canvas)', default: 1 },
1102
+ x: { type: 'number', description: 'Horizontal position in % from left (0=left edge, 50=center, 100=right edge)', default: 50 },
1103
+ y: { type: 'number', description: 'Vertical position in % from top (0=top edge, 50=center, 100=bottom edge)', default: 50 },
1104
+ width: { type: 'number', description: 'Width as % of canvas (e.g. 60 = 60%)', default: 100 },
1105
+ height: { type: 'number', description: 'Height as % of canvas', default: 100 },
368
1106
  rotation: { type: 'number', description: 'Rotation in degrees', default: 0 },
369
1107
  opacity: { type: 'number', description: 'Opacity 0-1', default: 1 },
1108
+ // Flex child properties (when inside a flex group or page-level flex)
1109
+ positioning: { type: 'string', enum: ['flow', 'absolute'], description: 'Child positioning inside flex groups. "absolute" opts out of flow layout.' },
1110
+ flexWidthMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Child width sizing mode inside flex groups (independent per-axis).' },
1111
+ flexHeightMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Child height sizing mode inside flex groups (independent per-axis).' },
1112
+ flexSizeMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Legacy: sets both axes. Prefer flexWidthMode/flexHeightMode.' },
1113
+ alignSelf: { type: 'string', enum: ['auto', 'start', 'center', 'end', 'stretch'], description: 'Child cross-axis alignment override inside flex groups.' },
1114
+ flexGrow: { type: 'number', minimum: 0, description: 'Flex grow factor. Controls how fill children share free space proportionally (like CSS flex-grow). Default 1 for fill children.' },
1115
+ // Shape style properties (rectangle/ellipse/polygon/star/line)
1116
+ fillColor: { type: 'string', description: 'Shape fill color in hex (e.g. #ffffff)' },
1117
+ fillOpacity: { type: 'number', description: 'Shape fill opacity (0-1)' },
1118
+ ...GRADIENT_FILL_SCHEMA_PROPS,
1119
+ strokeColor: { type: 'string', description: 'Shape stroke color in hex' },
1120
+ strokeWidth: { type: 'number', description: 'Shape stroke width (normalized; e.g. 0.005-0.02)' },
1121
+ strokeOpacity: { type: 'number', description: 'Shape stroke opacity (0-1)' },
1122
+ // Rectangle/Polygon/Star geometry
1123
+ cornerRadius: { type: 'number', description: 'Corner radius (normalized)' },
1124
+ // Polygon geometry
1125
+ sides: { type: 'number', description: 'Polygon sides (3+)' },
1126
+ // Star geometry
1127
+ innerRadiusRatio: { type: 'number', description: 'Star inner radius ratio (0-1)' },
1128
+ points: { type: 'number', description: 'Star points (3+)' },
1129
+ // Line geometry (normalized -1..1 coordinates)
1130
+ startX: { type: 'number', description: 'Line start X in % from left (0=left, 50=center, 100=right)' },
1131
+ startY: { type: 'number', description: 'Line start Y in % from top (0=top, 50=center, 100=bottom)' },
1132
+ endX: { type: 'number', description: 'Line end X in % from left (0=left, 50=center, 100=right)' },
1133
+ endY: { type: 'number', description: 'Line end Y in % from top (0=top, 50=center, 100=bottom)' },
370
1134
  // Text layer properties
371
1135
  content: { type: 'string', description: 'Text content (required for text layers)' },
1136
+ textType: { type: 'string', enum: ['frame'], description: 'Text mode (always "frame" — point text is deprecated)', default: 'frame' },
372
1137
  fontFamily: { type: 'string', description: 'Font family name', default: 'DM Sans' },
373
1138
  fontSize: { type: 'number', description: 'Font size in pixels', default: 72 },
374
1139
  fontWeight: { type: 'string', enum: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], description: 'Font weight', default: 'bold' },
375
1140
  color: { type: 'string', description: 'Text color in hex', default: '#ffffff' },
376
1141
  textAlign: { type: 'string', enum: ['left', 'center', 'right'], description: 'Text alignment', default: 'center' },
377
- letterSpacing: { type: 'number', description: 'Letter spacing in pixels', default: 0 },
378
- lineHeight: { type: 'number', description: 'Line height multiplier', default: 1.2 },
1142
+ verticalAlign: { type: 'string', enum: ['top', 'middle', 'bottom'], description: 'Vertical text alignment inside the frame. Defaults to "top" for frame text.' },
1143
+ letterSpacing: { type: 'number', description: 'Letter spacing in pixels (negative for tighter, e.g., -1 to -2 for headlines)', default: 0 },
1144
+ lineHeight: { type: 'number', description: 'Line height multiplier (0.9-1.0 for tight headlines, 1.3-1.5 for body)', default: 1.2 },
1145
+ textTransformMode: { type: 'string', enum: ['box'], description: 'Text sizing mode (always "box" — scale mode is deprecated)', default: 'box' },
1146
+ textBoxWidth: { type: 'number', description: 'Width in pixels for text wrapping when using box mode (e.g., 300-600 for readable lines)' },
1147
+ frameWidth: { type: 'number', description: 'Frame width normalized (0-1 relative to artboard width). Convenience alias for frameWidthPx.' },
1148
+ frameHeight: { type: 'number', description: 'Frame height normalized (0-1 relative to artboard height). Convenience alias for frameHeightPx.' },
1149
+ frameWidthPx: { type: 'number', description: 'Frame width in CSS pixels for frame text (resizing changes this, not fontSize)' },
1150
+ frameHeightPx: { type: 'number', description: 'Frame height in CSS pixels for frame text' },
1151
+ autoSize: { type: 'string', enum: ['none', 'height', 'widthAndHeight'], description: 'Auto-size mode for frame text' },
1152
+ // Text CSS properties
1153
+ textTransform: { type: 'string', enum: ['none', 'uppercase', 'lowercase', 'capitalize'], description: 'CSS text-transform (visual only, does not mutate stored content)' },
1154
+ textDecoration: { type: 'string', enum: ['none', 'underline', 'line-through', 'overline'], description: 'CSS text-decoration' },
1155
+ textDecorationColor: { type: 'string', description: 'Decoration line color in hex (defaults to text color)' },
1156
+ textDecorationThickness: { type: 'number', description: 'Decoration line thickness in CSS px' },
379
1157
  // Text animation properties (use list_text_animations to see all options)
380
1158
  animationType: {
381
1159
  type: 'string',
@@ -449,36 +1227,204 @@ exports.stateTools = [
449
1227
  // Image/Video properties
450
1228
  mediaUrl: { type: 'string', description: 'Media URL (required for image/video layers)' },
451
1229
  objectFit: { type: 'string', enum: ['cover', 'contain'], description: 'Object fit mode', default: 'cover' },
1230
+ // Shadow properties (all layer types)
1231
+ shadowOffsetX: { type: 'number', description: 'Shadow horizontal offset in CSS px' },
1232
+ shadowOffsetY: { type: 'number', description: 'Shadow vertical offset in CSS px' },
1233
+ shadowBlur: { type: 'number', description: 'Shadow blur radius in CSS px' },
1234
+ shadowColor: { type: 'string', description: 'Shadow color in hex' },
1235
+ shadowOpacity: { type: 'number', description: 'Shadow opacity (0-1)' },
1236
+ // Blend mode (all layer types)
1237
+ blendMode: { type: 'string', enum: ['normal', 'multiply', 'screen', 'darken', 'lighten', 'difference'], description: 'CSS-like blend mode' },
1238
+ // Margin (all layer types, for flex layout spacing)
1239
+ marginTop: { type: 'number', description: 'Margin top (normalized 0-1)' },
1240
+ marginRight: { type: 'number', description: 'Margin right (normalized 0-1)' },
1241
+ marginBottom: { type: 'number', description: 'Margin bottom (normalized 0-1)' },
1242
+ marginLeft: { type: 'number', description: 'Margin left (normalized 0-1)' },
1243
+ // Min/max size constraints (all layer types)
1244
+ minWidth: { type: 'number', description: 'Minimum width (normalized 0-1)' },
1245
+ maxWidth: { type: 'number', description: 'Maximum width (normalized 0-1)' },
1246
+ minHeight: { type: 'number', description: 'Minimum height (normalized 0-1)' },
1247
+ maxHeight: { type: 'number', description: 'Maximum height (normalized 0-1)' },
1248
+ // Transform origin (all layer types)
1249
+ originX: { type: 'number', description: 'Transform origin X (0=left, 0.5=center, 1=right)' },
1250
+ originY: { type: 'number', description: 'Transform origin Y (0=top, 0.5=center, 1=bottom)' },
452
1251
  },
453
1252
  required: ['type'],
454
1253
  },
455
1254
  },
1255
+ {
1256
+ name: 'add_group',
1257
+ description: 'Add a flexbox container with child layers. Works like CSS flexbox: set direction (column/row), alignment, and gap. Children are positioned automatically by the layout engine — do NOT set child x/y. Use this for ALL text layout (heading + subheading, title + date, etc.).',
1258
+ inputSchema: {
1259
+ type: 'object',
1260
+ properties: {
1261
+ name: { type: 'string', description: 'Group name' },
1262
+ layoutMode: { type: 'string', enum: ['row', 'column'], description: 'Flex direction: "column" stacks vertically (default, most common), "row" places side by side', default: 'column' },
1263
+ widthMode: { type: 'string', enum: ['hug', 'fixed'], description: 'Horizontal sizing mode: hug contents or fixed width' },
1264
+ heightMode: { type: 'string', enum: ['hug', 'fixed'], description: 'Vertical sizing mode: hug contents or fixed height' },
1265
+ gap: { type: 'number', description: 'Space between children as % of canvas (2=small, 4=medium, 6=large). Negative values overlap children.', default: 2 },
1266
+ justifyContent: { type: 'string', enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], description: 'Main-axis alignment (like CSS justify-content)', default: 'start' },
1267
+ alignItems: { type: 'string', enum: ['start', 'center', 'end'], description: 'Cross-axis alignment (like CSS align-items): "start"=left, "center"=centered, "end"=right', default: 'center' },
1268
+ wrap: { type: 'boolean', description: 'Wrap children to next line on overflow (like CSS flex-wrap)', default: false },
1269
+ padding: { type: 'number', description: 'Padding as % of canvas', default: 0 },
1270
+ paddingTop: { type: 'number', description: 'Padding top as % of canvas' },
1271
+ paddingRight: { type: 'number', description: 'Padding right as % of canvas' },
1272
+ paddingBottom: { type: 'number', description: 'Padding bottom as % of canvas' },
1273
+ paddingLeft: { type: 'number', description: 'Padding left as % of canvas' },
1274
+ fillColor: { type: 'string', description: 'Group background color (hex)' },
1275
+ fillOpacity: { type: 'number', description: 'Group background opacity (0-1)' },
1276
+ cornerRadius: { type: 'number', description: 'Corner radius' },
1277
+ x: { type: 'number', description: 'Horizontal position in % from left (0=left edge, 50=center, 100=right edge)', default: 50 },
1278
+ y: { type: 'number', description: 'Vertical position in % from top (0=top edge, 50=center, 100=bottom edge)', default: 50 },
1279
+ width: { type: 'number', description: 'Width as % of canvas (e.g. 60 = 60%)', default: 60 },
1280
+ height: { type: 'number', description: 'Height as % of canvas', default: 40 },
1281
+ rotation: { type: 'number', description: 'Rotation in degrees', default: 0 },
1282
+ opacity: { type: 'number', description: 'Opacity 0-1', default: 1 },
1283
+ children: {
1284
+ type: 'array',
1285
+ description: 'Child layers to add inside the group',
1286
+ items: {
1287
+ type: 'object',
1288
+ properties: {
1289
+ type: { type: 'string', enum: ['text', 'image', 'video', 'rectangle', 'ellipse', 'polygon', 'star', 'line'], description: 'Child layer type' },
1290
+ name: { type: 'string', description: 'Child layer name' },
1291
+ content: { type: 'string', description: 'Text content' },
1292
+ fontFamily: { type: 'string', description: 'Font family' },
1293
+ fontSize: { type: 'number', description: 'Font size in pixels' },
1294
+ fontWeight: { type: 'string', description: 'Font weight' },
1295
+ color: { type: 'string', description: 'Text color in hex' },
1296
+ textAlign: { type: 'string', enum: ['left', 'center', 'right'], description: 'Text alignment' },
1297
+ verticalAlign: { type: 'string', enum: ['top', 'middle', 'bottom'], description: 'Vertical text alignment inside the frame' },
1298
+ letterSpacing: { type: 'number', description: 'Letter spacing' },
1299
+ lineHeight: { type: 'number', description: 'Line height multiplier' },
1300
+ textType: { type: 'string', enum: ['frame'], description: 'Text mode (always "frame")' },
1301
+ textTransformMode: { type: 'string', enum: ['box'], description: 'Text sizing mode (always "box")' },
1302
+ textBoxWidth: { type: 'number', description: 'Width for text wrapping' },
1303
+ frameWidth: { type: 'number', description: 'Frame width normalized (0-1 relative to artboard width). Convenience alias for frameWidthPx.' },
1304
+ frameHeight: { type: 'number', description: 'Frame height normalized (0-1 relative to artboard height). Convenience alias for frameHeightPx.' },
1305
+ frameWidthPx: { type: 'number', description: 'Frame width in CSS pixels' },
1306
+ frameHeightPx: { type: 'number', description: 'Frame height in CSS pixels' },
1307
+ autoSize: { type: 'string', enum: ['none', 'height', 'widthAndHeight'], description: 'Auto-size mode' },
1308
+ textTransform: { type: 'string', enum: ['none', 'uppercase', 'lowercase', 'capitalize'], description: 'CSS text-transform' },
1309
+ textDecoration: { type: 'string', enum: ['none', 'underline', 'line-through', 'overline'], description: 'CSS text-decoration' },
1310
+ textDecorationColor: { type: 'string', description: 'Decoration line color (hex)' },
1311
+ textDecorationThickness: { type: 'number', description: 'Decoration line thickness in CSS px' },
1312
+ mediaUrl: { type: 'string', description: 'Media URL (for image/video)' },
1313
+ objectFit: { type: 'string', enum: ['cover', 'contain'], description: 'Object fit mode' },
1314
+ width: { type: 'number', description: 'Width as % of canvas (e.g. 60 = 60%)' },
1315
+ height: { type: 'number', description: 'Height as % of canvas' },
1316
+ opacity: { type: 'number', description: 'Opacity 0-1' },
1317
+ // Flex child properties
1318
+ positioning: { type: 'string', enum: ['flow', 'absolute'], description: 'Child positioning in group flex layout' },
1319
+ flexWidthMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Child width sizing in group flex layout' },
1320
+ flexHeightMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Child height sizing in group flex layout' },
1321
+ flexSizeMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Legacy: sets both axes. Prefer flexWidthMode/flexHeightMode.' },
1322
+ alignSelf: { type: 'string', enum: ['auto', 'start', 'center', 'end', 'stretch'], description: 'Child cross-axis alignment override in group flex layout' },
1323
+ flexGrow: { type: 'number', minimum: 0, description: 'Flex grow factor for proportional space distribution (like CSS flex-grow). Default 1 for fill children.' },
1324
+ // Shape style
1325
+ fillColor: { type: 'string', description: 'Shape fill color (hex)' },
1326
+ fillOpacity: { type: 'number', description: 'Shape fill opacity (0-1)' },
1327
+ ...GRADIENT_FILL_SCHEMA_PROPS,
1328
+ strokeColor: { type: 'string', description: 'Shape stroke color (hex)' },
1329
+ strokeWidth: { type: 'number', description: 'Shape stroke width (normalized; e.g. 0.005-0.02)' },
1330
+ strokeOpacity: { type: 'number', description: 'Shape stroke opacity (0-1)' },
1331
+ // Shape geometry
1332
+ cornerRadius: { type: 'number', description: 'Corner radius (normalized)' },
1333
+ sides: { type: 'number', description: 'Polygon sides (3+)' },
1334
+ innerRadiusRatio: { type: 'number', description: 'Star inner radius ratio (0-1)' },
1335
+ points: { type: 'number', description: 'Star points (3+)' },
1336
+ startX: { type: 'number', description: 'Line start X in % from left (0=left, 50=center, 100=right)' },
1337
+ startY: { type: 'number', description: 'Line start Y in % from top (0=top, 50=center, 100=bottom)' },
1338
+ endX: { type: 'number', description: 'Line end X in % from left (0=left, 50=center, 100=right)' },
1339
+ endY: { type: 'number', description: 'Line end Y in % from top (0=top, 50=center, 100=bottom)' },
1340
+ // Shadow (all layer types)
1341
+ shadowOffsetX: { type: 'number', description: 'Shadow horizontal offset in CSS px' },
1342
+ shadowOffsetY: { type: 'number', description: 'Shadow vertical offset in CSS px' },
1343
+ shadowBlur: { type: 'number', description: 'Shadow blur radius in CSS px' },
1344
+ shadowColor: { type: 'string', description: 'Shadow color in hex' },
1345
+ shadowOpacity: { type: 'number', description: 'Shadow opacity (0-1)' },
1346
+ blendMode: { type: 'string', enum: ['normal', 'multiply', 'screen', 'darken', 'lighten', 'difference'], description: 'CSS-like blend mode' },
1347
+ marginTop: { type: 'number', description: 'Margin top (normalized 0-1)' },
1348
+ marginRight: { type: 'number', description: 'Margin right (normalized 0-1)' },
1349
+ marginBottom: { type: 'number', description: 'Margin bottom (normalized 0-1)' },
1350
+ marginLeft: { type: 'number', description: 'Margin left (normalized 0-1)' },
1351
+ minWidth: { type: 'number', description: 'Minimum width (normalized 0-1)' },
1352
+ maxWidth: { type: 'number', description: 'Maximum width (normalized 0-1)' },
1353
+ minHeight: { type: 'number', description: 'Minimum height (normalized 0-1)' },
1354
+ maxHeight: { type: 'number', description: 'Maximum height (normalized 0-1)' },
1355
+ originX: { type: 'number', description: 'Transform origin X (0=left, 0.5=center, 1=right)' },
1356
+ originY: { type: 'number', description: 'Transform origin Y (0=top, 0.5=center, 1=bottom)' },
1357
+ cornerRadiusTopLeft: { type: 'number', description: 'Per-corner radius: top-left (for rectangle layers)' },
1358
+ cornerRadiusTopRight: { type: 'number', description: 'Per-corner radius: top-right (for rectangle layers)' },
1359
+ cornerRadiusBottomRight: { type: 'number', description: 'Per-corner radius: bottom-right (for rectangle layers)' },
1360
+ cornerRadiusBottomLeft: { type: 'number', description: 'Per-corner radius: bottom-left (for rectangle layers)' },
1361
+ },
1362
+ required: ['type'],
1363
+ },
1364
+ },
1365
+ },
1366
+ required: ['children'],
1367
+ },
1368
+ },
456
1369
  {
457
1370
  name: 'modify_layer',
458
- description: 'Modify an existing layer by ID',
1371
+ description: 'Modify an existing layer by ID. Supports text, image, video, group, and shape properties.',
459
1372
  inputSchema: {
460
1373
  type: 'object',
461
1374
  properties: {
462
1375
  layerId: { type: 'string', description: 'ID of the layer to modify' },
463
- // Transform properties (positive Y is UP/TOP, negative Y is down/bottom)
464
- x: { type: 'number', description: 'X position (-1 to 1, 0 is center)' },
465
- y: { type: 'number', description: 'Y position (-1 to 1, POSITIVE is UP/TOP, negative is down/bottom)' },
466
- width: { type: 'number', description: 'Width' },
467
- height: { type: 'number', description: 'Height' },
1376
+ // Transform properties (CSS % coordinates)
1377
+ x: { type: 'number', description: 'Horizontal position in % from left (0=left edge, 50=center, 100=right edge)' },
1378
+ y: { type: 'number', description: 'Vertical position in % from top (0=top edge, 50=center, 100=bottom edge)' },
1379
+ width: { type: 'number', description: 'Width as % of canvas (e.g. 60 = 60%)' },
1380
+ height: { type: 'number', description: 'Height as % of canvas' },
468
1381
  rotation: { type: 'number', description: 'Rotation in degrees' },
469
1382
  opacity: { type: 'number', description: 'Opacity 0-1' },
470
1383
  visible: { type: 'boolean', description: 'Layer visibility' },
471
1384
  locked: { type: 'boolean', description: 'Layer lock state' },
472
1385
  name: { type: 'string', description: 'Layer name' },
1386
+ // Flex child properties
1387
+ positioning: { type: 'string', enum: ['flow', 'absolute'], description: 'Child positioning mode inside flex groups' },
1388
+ flexWidthMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Child width sizing mode inside flex groups (independent per-axis).' },
1389
+ flexHeightMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Child height sizing mode inside flex groups (independent per-axis).' },
1390
+ flexSizeMode: { type: 'string', enum: ['hug', 'fixed', 'fill'], description: 'Legacy: sets both axes. Prefer flexWidthMode/flexHeightMode.' },
1391
+ alignSelf: { type: 'string', enum: ['auto', 'start', 'center', 'end', 'stretch'], description: 'Child cross-axis alignment override inside flex groups' },
1392
+ flexGrow: { type: 'number', minimum: 0, description: 'Flex grow factor for proportional space distribution (like CSS flex-grow). Default 1 for fill children.' },
1393
+ // Shape style properties (rectangle/ellipse/polygon/star/line)
1394
+ strokeColor: { type: 'string', description: 'Shape stroke color (hex)' },
1395
+ strokeWidth: { type: 'number', description: 'Shape stroke width (normalized; e.g. 0.005-0.02)' },
1396
+ strokeOpacity: { type: 'number', description: 'Shape stroke opacity (0-1)' },
1397
+ // Shape geometry
1398
+ sides: { type: 'number', description: 'Polygon sides (3+)' },
1399
+ innerRadiusRatio: { type: 'number', description: 'Star inner radius ratio (0-1)' },
1400
+ points: { type: 'number', description: 'Star points (3+)' },
1401
+ startX: { type: 'number', description: 'Line start X in % from left (0=left, 50=center, 100=right)' },
1402
+ startY: { type: 'number', description: 'Line start Y in % from top (0=top, 50=center, 100=bottom)' },
1403
+ endX: { type: 'number', description: 'Line end X in % from left (0=left, 50=center, 100=right)' },
1404
+ endY: { type: 'number', description: 'Line end Y in % from top (0=top, 50=center, 100=bottom)' },
473
1405
  // Text layer properties
474
1406
  content: { type: 'string' },
1407
+ textType: { type: 'string', enum: ['frame'], description: 'Text mode (always "frame")' },
475
1408
  fontFamily: { type: 'string' },
476
1409
  fontSize: { type: 'number' },
477
1410
  fontWeight: { type: 'string' },
478
1411
  color: { type: 'string' },
479
1412
  textAlign: { type: 'string' },
1413
+ verticalAlign: { type: 'string', enum: ['top', 'middle', 'bottom'], description: 'Vertical text alignment inside the frame' },
480
1414
  letterSpacing: { type: 'number' },
481
1415
  lineHeight: { type: 'number' },
1416
+ textTransformMode: { type: 'string', enum: ['box'] },
1417
+ textBoxWidth: { type: 'number' },
1418
+ frameWidth: { type: 'number', description: 'Frame width normalized (0-1 relative to artboard width). Convenience alias for frameWidthPx.' },
1419
+ frameHeight: { type: 'number', description: 'Frame height normalized (0-1 relative to artboard height). Convenience alias for frameHeightPx.' },
1420
+ frameWidthPx: { type: 'number' },
1421
+ frameHeightPx: { type: 'number' },
1422
+ autoSize: { type: 'string', enum: ['none', 'height', 'widthAndHeight'] },
1423
+ // Text CSS properties
1424
+ textTransform: { type: 'string', enum: ['none', 'uppercase', 'lowercase', 'capitalize'], description: 'CSS text-transform (visual only, does not mutate stored content)' },
1425
+ textDecoration: { type: 'string', enum: ['none', 'underline', 'line-through', 'overline'], description: 'CSS text-decoration' },
1426
+ textDecorationColor: { type: 'string', description: 'Decoration line color in hex (defaults to text color)' },
1427
+ textDecorationThickness: { type: 'number', description: 'Decoration line thickness in CSS px' },
482
1428
  // Text animation properties (all type-specific params supported - see add_layer for descriptions)
483
1429
  animationType: {
484
1430
  type: 'string',
@@ -535,6 +1481,49 @@ exports.stateTools = [
535
1481
  // Media properties
536
1482
  mediaUrl: { type: 'string' },
537
1483
  objectFit: { type: 'string' },
1484
+ // Group layout properties
1485
+ layoutMode: { type: 'string', enum: ['row', 'column'], description: 'Group layout direction' },
1486
+ widthMode: { type: 'string', enum: ['hug', 'fixed'], description: 'Group horizontal sizing mode' },
1487
+ heightMode: { type: 'string', enum: ['hug', 'fixed'], description: 'Group vertical sizing mode' },
1488
+ gap: { type: 'number', description: 'Space between children as % of canvas (2=small, 4=medium, 6=large)' },
1489
+ justifyContent: { type: 'string', enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], description: 'Group main-axis alignment' },
1490
+ alignItems: { type: 'string', enum: ['start', 'center', 'end'], description: 'Group cross-axis alignment' },
1491
+ wrap: { type: 'boolean', description: 'Group wrap children' },
1492
+ padding: { type: 'number', description: 'Padding as % of canvas' },
1493
+ paddingTop: { type: 'number', description: 'Padding top as % of canvas' },
1494
+ paddingRight: { type: 'number', description: 'Padding right as % of canvas' },
1495
+ paddingBottom: { type: 'number', description: 'Padding bottom as % of canvas' },
1496
+ paddingLeft: { type: 'number', description: 'Padding left as % of canvas' },
1497
+ fillColor: { type: 'string', description: 'Fill color (group background OR shape fill)' },
1498
+ fillOpacity: { type: 'number', description: 'Fill opacity (group background OR shape fill)' },
1499
+ ...GRADIENT_FILL_SCHEMA_PROPS,
1500
+ cornerRadius: { type: 'number', description: 'Corner radius (group OR shape)' },
1501
+ // Shadow properties (all layer types)
1502
+ shadowOffsetX: { type: 'number', description: 'Shadow horizontal offset in CSS px' },
1503
+ shadowOffsetY: { type: 'number', description: 'Shadow vertical offset in CSS px' },
1504
+ shadowBlur: { type: 'number', description: 'Shadow blur radius in CSS px' },
1505
+ shadowColor: { type: 'string', description: 'Shadow color in hex' },
1506
+ shadowOpacity: { type: 'number', description: 'Shadow opacity (0-1)' },
1507
+ // Blend mode (all layer types)
1508
+ blendMode: { type: 'string', enum: ['normal', 'multiply', 'screen', 'darken', 'lighten', 'difference'], description: 'CSS-like blend mode' },
1509
+ // Margin (all layer types)
1510
+ marginTop: { type: 'number', description: 'Margin top (normalized 0-1)' },
1511
+ marginRight: { type: 'number', description: 'Margin right (normalized 0-1)' },
1512
+ marginBottom: { type: 'number', description: 'Margin bottom (normalized 0-1)' },
1513
+ marginLeft: { type: 'number', description: 'Margin left (normalized 0-1)' },
1514
+ // Min/max size constraints (all layer types)
1515
+ minWidth: { type: 'number', description: 'Minimum width (normalized 0-1)' },
1516
+ maxWidth: { type: 'number', description: 'Maximum width (normalized 0-1)' },
1517
+ minHeight: { type: 'number', description: 'Minimum height (normalized 0-1)' },
1518
+ maxHeight: { type: 'number', description: 'Maximum height (normalized 0-1)' },
1519
+ // Transform origin (all layer types)
1520
+ originX: { type: 'number', description: 'Transform origin X (0=left, 0.5=center, 1=right)' },
1521
+ originY: { type: 'number', description: 'Transform origin Y (0=top, 0.5=center, 1=bottom)' },
1522
+ // Per-corner radius (rectangle and group layers)
1523
+ cornerRadiusTopLeft: { type: 'number', description: 'Per-corner radius: top-left' },
1524
+ cornerRadiusTopRight: { type: 'number', description: 'Per-corner radius: top-right' },
1525
+ cornerRadiusBottomRight: { type: 'number', description: 'Per-corner radius: bottom-right' },
1526
+ cornerRadiusBottomLeft: { type: 'number', description: 'Per-corner radius: bottom-left' },
538
1527
  },
539
1528
  required: ['layerId'],
540
1529
  },
@@ -560,12 +1549,15 @@ IMPORTANT - Two effect pipelines:
560
1549
  'dither-floyd-steinberg', 'dither-atkinson', 'dither-jarvis-judice-ninke',
561
1550
  'dither-stucki', 'dither-burkes', 'dither-sierra', 'dither-two-row-sierra',
562
1551
  'dither-sierra-lite',
1552
+ 'color-separation',
563
1553
  // Halftone (WebGPU - supports post-processes)
564
1554
  'halftone-mono', 'halftone-cmyk',
565
1555
  // Glitch (WebGPU - supports post-processes)
566
1556
  'glitch-chromatic', 'glitch-digital', 'glitch-vhs', 'glitch-weird',
567
1557
  // Art (WebGPU - supports post-processes)
568
1558
  'art-kuwahara', 'art-crosshatch', 'art-lineart', 'art-engraving', 'art-stipple',
1559
+ // Special (WebGPU - supports post-processes)
1560
+ 'special-warp',
569
1561
  ],
570
1562
  description: 'Effect ID to apply',
571
1563
  },
@@ -611,7 +1603,7 @@ Common combinations:
611
1603
  'scanlines', 'vignette', 'chromatic-aberration', 'curvature',
612
1604
  'grain', 'noise', 'pixelate', 'wave', 'rgb-glitch', 'brightness-contrast',
613
1605
  'color-tint', 'palette', 'jitter', 'bloom', 'dot-screen', 'sepia',
614
- 'grid', 'light-beams', 'motion-blur',
1606
+ 'grid', 'light-beams', 'motion-blur', 'blob-tracking',
615
1607
  ],
616
1608
  description: 'Post-process type',
617
1609
  },
@@ -669,6 +1661,17 @@ Common combinations:
669
1661
  density: { type: 'number', description: 'Light-beams: ray spread (0.5-2)', default: 1 },
670
1662
  animated: { type: 'boolean', description: 'Light-beams/Grid: enable animation', default: false },
671
1663
  particleAmount: { type: 'number', description: 'Light-beams: particle density (0-1)', default: 0.5 },
1664
+ // Blob Tracking
1665
+ rectScale: { type: 'number', description: 'Blob-tracking: rectangle size multiplier (0.3-3)', default: 1 },
1666
+ zoom: { type: 'number', description: 'Blob-tracking: zoom factor inside rectangles (0.2-1.0)', default: 0.6 },
1667
+ invertChance: { type: 'number', description: 'Blob-tracking: chance of inverting colors (0-1)', default: 0.08 },
1668
+ animSpeed: { type: 'number', description: 'Blob-tracking: animation speed (0-3)', default: 1 },
1669
+ lineColor: { type: 'string', description: 'Blob-tracking: connecting line color (hex)', default: '#ffffff' },
1670
+ lineOpacity: { type: 'number', description: 'Blob-tracking: line opacity (0-1)', default: 0.7 },
1671
+ rectColor: { type: 'string', description: 'Blob-tracking: rectangle outline color (hex)', default: '#ffff00' },
1672
+ rectOpacity: { type: 'number', description: 'Blob-tracking: rectangle opacity (0-1)', default: 1 },
1673
+ showLabels: { type: 'boolean', description: 'Blob-tracking: show tracking ID labels', default: true },
1674
+ glow: { type: 'number', description: 'Blob-tracking: glow intensity (0-1)', default: 0.3 },
672
1675
  },
673
1676
  required: ['type'],
674
1677
  },
@@ -684,6 +1687,22 @@ Common combinations:
684
1687
  required: ['layerId'],
685
1688
  },
686
1689
  },
1690
+ {
1691
+ name: 'move_layer',
1692
+ description: 'Move a layer in the layer stack (z-order). Cannot move the background layer (index 0).',
1693
+ inputSchema: {
1694
+ type: 'object',
1695
+ properties: {
1696
+ layerId: { type: 'string', description: 'ID of the layer to move' },
1697
+ direction: {
1698
+ type: 'string',
1699
+ enum: ['up', 'down', 'top', 'bottom'],
1700
+ description: 'Where to move it: up/down = one step, top/bottom = to front/back',
1701
+ },
1702
+ },
1703
+ required: ['layerId', 'direction'],
1704
+ },
1705
+ },
687
1706
  {
688
1707
  name: 'get_state',
689
1708
  description: 'Get the current poster state as LayerShareState JSON (for debugging or verification)',
@@ -733,11 +1752,24 @@ async function handleStateTool(name, args) {
733
1752
  case 'create_poster': {
734
1753
  const aspectRatio = params.aspectRatio || '9:16';
735
1754
  const backgroundColor = params.backgroundColor || '#1a1a1a';
1755
+ const canvasColor = params.canvasColor;
1756
+ const overflow = params.overflow;
736
1757
  currentPoster = {
737
- canvas: { aspectRatio, backgroundColor },
1758
+ canvas: {
1759
+ aspectRatio,
1760
+ backgroundColor,
1761
+ ...(canvasColor && { canvasColor }),
1762
+ ...(overflow && { overflow }),
1763
+ layoutMode: 'column',
1764
+ alignItems: 'center',
1765
+ justifyContent: 'start',
1766
+ gap: 0.04,
1767
+ padding: { top: 0.08, right: 0.08, bottom: 0.08, left: 0.08 },
1768
+ },
738
1769
  layers: [createDefaultBackgroundLayer(backgroundColor)],
739
1770
  postProcesses: [],
740
1771
  };
1772
+ persistState();
741
1773
  return {
742
1774
  content: [
743
1775
  {
@@ -752,12 +1784,110 @@ async function handleStateTool(name, args) {
752
1784
  ],
753
1785
  };
754
1786
  }
755
- case 'set_background': {
756
- if (!currentPoster) {
757
- return {
758
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1787
+ case 'clear_poster': {
1788
+ ensurePoster();
1789
+ const bgLayer = currentPoster.layers.find((l) => l.type === 'background');
1790
+ const defaultBg = createDefaultBackgroundLayer(currentPoster.canvas.backgroundColor || '#1a1a1a');
1791
+ currentPoster = {
1792
+ canvas: { ...currentPoster.canvas },
1793
+ layers: bgLayer ? [bgLayer] : [defaultBg],
1794
+ postProcesses: [],
1795
+ };
1796
+ persistState();
1797
+ return {
1798
+ content: [{
1799
+ type: 'text',
1800
+ text: JSON.stringify({
1801
+ success: true,
1802
+ message: 'Poster cleared — all content layers, effects, and post-processes removed.',
1803
+ }),
1804
+ }],
1805
+ };
1806
+ }
1807
+ case 'set_page_layout': {
1808
+ ensurePoster();
1809
+ const layoutMode = params.layoutMode;
1810
+ if (layoutMode !== undefined) {
1811
+ if (layoutMode !== 'absolute' && layoutMode !== 'row' && layoutMode !== 'column') {
1812
+ return {
1813
+ content: [{ type: 'text', text: 'Error: layoutMode must be one of: absolute, row, column' }],
1814
+ };
1815
+ }
1816
+ currentPoster.canvas.layoutMode = layoutMode;
1817
+ }
1818
+ if (params.justifyContent !== undefined) {
1819
+ currentPoster.canvas.justifyContent = params.justifyContent;
1820
+ }
1821
+ if (params.alignItems !== undefined) {
1822
+ currentPoster.canvas.alignItems = params.alignItems;
1823
+ }
1824
+ if (params.wrap !== undefined) {
1825
+ currentPoster.canvas.wrap = Boolean(params.wrap);
1826
+ }
1827
+ if (params.gap !== undefined) {
1828
+ currentPoster.canvas.gap = cssPercentToSize(params.gap, (currentPoster.canvas.gap ?? 0) * 100);
1829
+ }
1830
+ if (params.padding !== undefined ||
1831
+ params.paddingTop !== undefined ||
1832
+ params.paddingRight !== undefined ||
1833
+ params.paddingBottom !== undefined ||
1834
+ params.paddingLeft !== undefined) {
1835
+ const existing = currentPoster.canvas.padding || { top: 0, right: 0, bottom: 0, left: 0 };
1836
+ const uniformPct = params.padding !== undefined ? parseNumericInput(params.padding) : undefined;
1837
+ const uniform = uniformPct !== undefined ? cssPercentToSize(uniformPct, 0) : undefined;
1838
+ const nextPadding = {
1839
+ top: params.paddingTop !== undefined ? cssPercentToSize(params.paddingTop, 0) : uniform ?? existing.top,
1840
+ right: params.paddingRight !== undefined ? cssPercentToSize(params.paddingRight, 0) : uniform ?? existing.right,
1841
+ bottom: params.paddingBottom !== undefined ? cssPercentToSize(params.paddingBottom, 0) : uniform ?? existing.bottom,
1842
+ left: params.paddingLeft !== undefined ? cssPercentToSize(params.paddingLeft, 0) : uniform ?? existing.left,
759
1843
  };
1844
+ currentPoster.canvas.padding = nextPadding;
760
1845
  }
1846
+ persistState();
1847
+ return {
1848
+ content: [
1849
+ {
1850
+ type: 'text',
1851
+ text: JSON.stringify({
1852
+ success: true,
1853
+ message: `Page layout updated (${currentPoster.canvas.layoutMode ?? 'absolute'})`,
1854
+ canvas: currentPoster.canvas,
1855
+ }, null, 2),
1856
+ },
1857
+ ],
1858
+ };
1859
+ }
1860
+ case 'modify_canvas': {
1861
+ ensurePoster();
1862
+ const canvas = currentPoster.canvas;
1863
+ if (params.aspectRatio)
1864
+ canvas.aspectRatio = params.aspectRatio;
1865
+ if (params.backgroundColor)
1866
+ canvas.backgroundColor = params.backgroundColor;
1867
+ if (params.canvasColor !== undefined)
1868
+ canvas.canvasColor = params.canvasColor;
1869
+ if (params.overflow !== undefined)
1870
+ canvas.overflow = params.overflow;
1871
+ persistState();
1872
+ return {
1873
+ content: [
1874
+ {
1875
+ type: 'text',
1876
+ text: JSON.stringify({
1877
+ success: true,
1878
+ canvas: {
1879
+ aspectRatio: canvas.aspectRatio,
1880
+ backgroundColor: canvas.backgroundColor,
1881
+ canvasColor: canvas.canvasColor,
1882
+ overflow: canvas.overflow,
1883
+ },
1884
+ }, null, 2),
1885
+ },
1886
+ ],
1887
+ };
1888
+ }
1889
+ case 'set_background': {
1890
+ ensurePoster();
761
1891
  // Background is always at index 0
762
1892
  const bgLayer = currentPoster.layers[0];
763
1893
  if (bgLayer.type !== 'background') {
@@ -765,13 +1895,34 @@ async function handleStateTool(name, args) {
765
1895
  content: [{ type: 'text', text: 'Error: Background layer not found at index 0' }],
766
1896
  };
767
1897
  }
768
- const contentType = params.type || 'solid';
1898
+ const requestedType = params.type || 'solid';
1899
+ const contentType = requestedType === 'gradient' ? 'solid' : requestedType;
769
1900
  bgLayer.contentType = contentType;
770
- if (contentType === 'solid') {
771
- if (params.color) {
772
- bgLayer.solidColor = params.color;
773
- currentPoster.canvas.backgroundColor = params.color;
774
- }
1901
+ if (requestedType === 'gradient') {
1902
+ const gradient = buildFillFromParams(params)
1903
+ ?? normalizeLinearGradientFill(DEFAULT_LINEAR_GRADIENT_FILL);
1904
+ bgLayer.fill = gradient;
1905
+ bgLayer.solidColor = gradient.stops[0].color;
1906
+ currentPoster.canvas.backgroundColor = gradient.stops[0].color;
1907
+ delete bgLayer.inputMedia;
1908
+ }
1909
+ else if (contentType === 'solid') {
1910
+ const solidColor = params.color ||
1911
+ (bgLayer.fill?.type === 'solid'
1912
+ ? bgLayer.fill.color
1913
+ : bgLayer.fill
1914
+ ? bgLayer.fill.stops[0]?.color
1915
+ : undefined) ||
1916
+ bgLayer.solidColor ||
1917
+ currentPoster.canvas.backgroundColor ||
1918
+ '#5c5c5c';
1919
+ bgLayer.solidColor = solidColor;
1920
+ bgLayer.fill = {
1921
+ type: 'solid',
1922
+ color: solidColor,
1923
+ opacity: 1,
1924
+ };
1925
+ currentPoster.canvas.backgroundColor = solidColor;
775
1926
  delete bgLayer.inputMedia;
776
1927
  }
777
1928
  else if (contentType === 'image' && params.imageUrl) {
@@ -794,6 +1945,7 @@ async function handleStateTool(name, args) {
794
1945
  objectFit: params.objectFit || 'cover',
795
1946
  };
796
1947
  }
1948
+ persistState();
797
1949
  return {
798
1950
  content: [
799
1951
  {
@@ -808,11 +1960,7 @@ async function handleStateTool(name, args) {
808
1960
  };
809
1961
  }
810
1962
  case 'add_layer': {
811
- if (!currentPoster) {
812
- return {
813
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
814
- };
815
- }
1963
+ ensurePoster();
816
1964
  const layerType = params.type;
817
1965
  if (!layerType) {
818
1966
  return {
@@ -824,38 +1972,76 @@ async function handleStateTool(name, args) {
824
1972
  content: [{ type: 'text', text: 'Error: Use set_background to modify the background layer' }],
825
1973
  };
826
1974
  }
1975
+ const isShapeType = ['rectangle', 'ellipse', 'polygon', 'star', 'line'].includes(layerType);
827
1976
  const layerId = generateId();
1977
+ const visualOverrides = parseVisualOverrides(params);
1978
+ const originOverrides = parseTransformOrigin(params);
828
1979
  const baseLayer = {
829
1980
  id: layerId,
830
1981
  type: layerType,
831
1982
  name: params.name || `${layerType.charAt(0).toUpperCase() + layerType.slice(1)} Layer`,
832
1983
  visible: true,
833
1984
  locked: false,
1985
+ ...parseFlexChildProps(params),
1986
+ ...visualOverrides,
834
1987
  transform: {
835
- x: params.x ?? 0,
836
- y: params.y ?? 0,
837
- width: params.width ?? 1,
838
- height: params.height ?? 1,
1988
+ x: cssPercentToX(params.x, 50),
1989
+ y: cssPercentToY(params.y, 50),
1990
+ // Shapes store their intrinsic size in geometry fields; keep transform scale at 1.
1991
+ width: isShapeType ? 1 : cssPercentToSize(params.width, 100),
1992
+ height: isShapeType ? 1 : cssPercentToSize(params.height, 100),
839
1993
  rotation: params.rotation ?? 0,
840
- opacity: params.opacity ?? 1,
1994
+ opacity: normalizeOpacity(params.opacity, 1),
1995
+ ...originOverrides,
841
1996
  },
842
1997
  };
843
1998
  let layer;
844
1999
  if (layerType === 'text') {
845
2000
  const animation = buildAnimationSettings(params);
846
- // Use same defaults as app's DEFAULT_TEXT_LAYER to avoid encoding unnecessary params in URLs
2001
+ const artboardAspect = getArtboardAspect(currentPoster.canvas.aspectRatio);
2002
+ const content = params.content || 'Text';
2003
+ const fontSize = params.fontSize || 72;
2004
+ const frameWidthNormalized = parseNormalizedFrameDimension(params.frameWidth);
2005
+ const frameHeightNormalized = parseNormalizedFrameDimension(params.frameHeight);
2006
+ const frameWidthPxFromNormalized = frameWidthNormalized !== undefined
2007
+ ? textNormalizedToPx(frameWidthNormalized, artboardAspect, 'width')
2008
+ : undefined;
2009
+ const frameHeightPxFromNormalized = frameHeightNormalized !== undefined
2010
+ ? textNormalizedToPx(frameHeightNormalized, artboardAspect, 'height')
2011
+ : undefined;
2012
+ // All text is frame text — silently convert any 'point'/'scale' requests
2013
+ let frameWidthPx = typeof params.frameWidthPx === 'number' ? params.frameWidthPx : frameWidthPxFromNormalized;
2014
+ const frameHeightPx = typeof params.frameHeightPx === 'number' ? params.frameHeightPx : frameHeightPxFromNormalized;
2015
+ const textBoxWidth = typeof params.textBoxWidth === 'number' ? params.textBoxWidth : undefined;
2016
+ const requestedAutoSize = typeof params.autoSize === 'string'
2017
+ ? params.autoSize
2018
+ : undefined;
2019
+ const autoSize = requestedAutoSize ?? 'height';
2020
+ if (frameWidthPx == null && textBoxWidth == null) {
2021
+ const defaultFrameWidthNormalized = fontSize >= 72 ? 0.88 : 0.78;
2022
+ frameWidthPx = textNormalizedToPx(defaultFrameWidthNormalized, artboardAspect, 'width');
2023
+ }
847
2024
  layer = {
848
2025
  ...baseLayer,
849
2026
  type: 'text',
850
- content: params.content || 'Text',
2027
+ content,
2028
+ textType: 'frame',
851
2029
  fontFamily: normalizeFontFamily(params.fontFamily),
852
- fontSize: params.fontSize || 72,
2030
+ fontSize,
853
2031
  fontWeight: normalizeFontWeight(params.fontWeight),
854
2032
  color: params.color || '#ffffff',
855
2033
  textAlign: params.textAlign || 'center',
2034
+ ...(params.verticalAlign ? { verticalAlign: params.verticalAlign } : {}),
856
2035
  letterSpacing: params.letterSpacing ?? 0,
857
2036
  lineHeight: params.lineHeight ?? 1.2,
858
- ...(animation && { animation }),
2037
+ textTransformMode: 'box',
2038
+ ...(typeof textBoxWidth === 'number' ? { textBoxWidth } : {}),
2039
+ ...(typeof frameWidthPx === 'number' ? { frameWidthPx } : {}),
2040
+ ...(typeof frameHeightPx === 'number' ? { frameHeightPx } : {}),
2041
+ ...(typeof autoSize === 'string' ? { autoSize } : {}),
2042
+ ...(animation ? { animation } : {}),
2043
+ ...(parseTextTransform(params) ? { textTransform: parseTextTransform(params) } : {}),
2044
+ ...parseTextDecoration(params),
859
2045
  };
860
2046
  }
861
2047
  else if (layerType === 'image') {
@@ -892,10 +2078,140 @@ async function handleStateTool(name, args) {
892
2078
  playbackSpeed: 1,
893
2079
  };
894
2080
  }
2081
+ else if (layerType === 'rectangle') {
2082
+ const fillColor = normalizeHexColor(params.fillColor) || '#3b82f6';
2083
+ const fillOpacity = normalizeOpacity(params.fillOpacity, 1);
2084
+ const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2085
+ const gradientFill = buildFillFromParams(params);
2086
+ layer = {
2087
+ ...baseLayer,
2088
+ type: 'rectangle',
2089
+ style: {
2090
+ fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2091
+ fillType: 'solid',
2092
+ fillColor,
2093
+ fillOpacity,
2094
+ strokeColor,
2095
+ strokeWidth: normalizeStrokeWidth(params.strokeWidth, 0),
2096
+ strokeOpacity: normalizeOpacity(params.strokeOpacity, 1),
2097
+ strokeCap: 'round',
2098
+ strokeJoin: 'round',
2099
+ },
2100
+ shapeWidth: cssPercentToSize(params.width, 20),
2101
+ shapeHeight: cssPercentToSize(params.height, 20),
2102
+ cornerRadius: normalizeCornerRadius(params.cornerRadius, 0),
2103
+ ...(parseCornerRadii(params) ? { cornerRadii: parseCornerRadii(params) } : {}),
2104
+ };
2105
+ }
2106
+ else if (layerType === 'ellipse') {
2107
+ const fillColor = normalizeHexColor(params.fillColor) || '#3b82f6';
2108
+ const fillOpacity = normalizeOpacity(params.fillOpacity, 1);
2109
+ const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2110
+ const w = cssPercentToSize(params.width, 20);
2111
+ const h = cssPercentToSize(params.height, 20);
2112
+ const gradientFill = buildFillFromParams(params);
2113
+ layer = {
2114
+ ...baseLayer,
2115
+ type: 'ellipse',
2116
+ style: {
2117
+ fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2118
+ fillType: 'solid',
2119
+ fillColor,
2120
+ fillOpacity,
2121
+ strokeColor,
2122
+ strokeWidth: normalizeStrokeWidth(params.strokeWidth, 0),
2123
+ strokeOpacity: normalizeOpacity(params.strokeOpacity, 1),
2124
+ strokeCap: 'round',
2125
+ strokeJoin: 'round',
2126
+ },
2127
+ radiusX: w / 2,
2128
+ radiusY: h / 2,
2129
+ };
2130
+ }
2131
+ else if (layerType === 'polygon') {
2132
+ const fillColor = normalizeHexColor(params.fillColor) || '#3b82f6';
2133
+ const fillOpacity = normalizeOpacity(params.fillOpacity, 1);
2134
+ const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2135
+ const w = cssPercentToSize(params.width, 20);
2136
+ const h = cssPercentToSize(params.height, 20);
2137
+ const gradientFill = buildFillFromParams(params);
2138
+ layer = {
2139
+ ...baseLayer,
2140
+ type: 'polygon',
2141
+ style: {
2142
+ fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2143
+ fillType: 'solid',
2144
+ fillColor,
2145
+ fillOpacity,
2146
+ strokeColor,
2147
+ strokeWidth: normalizeStrokeWidth(params.strokeWidth, 0),
2148
+ strokeOpacity: normalizeOpacity(params.strokeOpacity, 1),
2149
+ strokeCap: 'round',
2150
+ strokeJoin: 'round',
2151
+ },
2152
+ radius: Math.min(w, h) / 2,
2153
+ sides: normalizePositiveInt(params.sides, 6, 3, 32),
2154
+ cornerRadius: normalizeCornerRadius(params.cornerRadius, 0),
2155
+ };
2156
+ }
2157
+ else if (layerType === 'star') {
2158
+ const fillColor = normalizeHexColor(params.fillColor) || '#3b82f6';
2159
+ const fillOpacity = normalizeOpacity(params.fillOpacity, 1);
2160
+ const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2161
+ const w = cssPercentToSize(params.width, 20);
2162
+ const h = cssPercentToSize(params.height, 20);
2163
+ const gradientFill = buildFillFromParams(params);
2164
+ layer = {
2165
+ ...baseLayer,
2166
+ type: 'star',
2167
+ style: {
2168
+ fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2169
+ fillType: 'solid',
2170
+ fillColor,
2171
+ fillOpacity,
2172
+ strokeColor,
2173
+ strokeWidth: normalizeStrokeWidth(params.strokeWidth, 0),
2174
+ strokeOpacity: normalizeOpacity(params.strokeOpacity, 1),
2175
+ strokeCap: 'round',
2176
+ strokeJoin: 'round',
2177
+ },
2178
+ outerRadius: Math.min(w, h) / 2,
2179
+ innerRadiusRatio: clamp(parseNumericInput(params.innerRadiusRatio), 0.05, 0.95, 0.5),
2180
+ points: normalizePositiveInt(params.points, 5, 3, 32),
2181
+ cornerRadius: normalizeCornerRadius(params.cornerRadius, 0),
2182
+ };
2183
+ }
2184
+ else if (layerType === 'line') {
2185
+ const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2186
+ const strokeWidth = normalizeStrokeWidth(params.strokeWidth, 0.005);
2187
+ const strokeOpacity = normalizeOpacity(params.strokeOpacity, 1);
2188
+ const halfW = clamp(parseNumericInput(params.width), 0.01, 1, 0.6);
2189
+ const halfH = clamp(parseNumericInput(params.height), 0, 1, 0);
2190
+ const startX = cssPercentToX(params.startX, (-halfW + 1) * 50);
2191
+ const endX = cssPercentToX(params.endX, (halfW + 1) * 50);
2192
+ const startY = cssPercentToY(params.startY, (1 + halfH) * 50);
2193
+ const endY = cssPercentToY(params.endY, (1 - halfH) * 50);
2194
+ layer = {
2195
+ ...baseLayer,
2196
+ type: 'line',
2197
+ style: {
2198
+ strokeColor,
2199
+ strokeWidth,
2200
+ strokeOpacity,
2201
+ strokeCap: 'round',
2202
+ strokeJoin: 'round',
2203
+ },
2204
+ startX,
2205
+ startY,
2206
+ endX,
2207
+ endY,
2208
+ };
2209
+ }
895
2210
  else {
896
2211
  layer = baseLayer;
897
2212
  }
898
2213
  currentPoster.layers.push(layer);
2214
+ persistState();
899
2215
  return {
900
2216
  content: [
901
2217
  {
@@ -910,12 +2226,308 @@ async function handleStateTool(name, args) {
910
2226
  ],
911
2227
  };
912
2228
  }
913
- case 'modify_layer': {
914
- if (!currentPoster) {
2229
+ case 'add_group': {
2230
+ ensurePoster();
2231
+ const children = params.children;
2232
+ if (!children || !Array.isArray(children) || children.length === 0) {
915
2233
  return {
916
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
2234
+ content: [{ type: 'text', text: 'Error: children array is required and must have at least 1 child' }],
917
2235
  };
918
2236
  }
2237
+ const groupId = generateId();
2238
+ const childLayers = [];
2239
+ const artboardAspect = getArtboardAspect(currentPoster.canvas.aspectRatio);
2240
+ for (const childParams of children) {
2241
+ const childType = childParams.type || 'text';
2242
+ const isChildShapeType = ['rectangle', 'ellipse', 'polygon', 'star', 'line'].includes(childType);
2243
+ const childId = generateId();
2244
+ const childVisualOverrides = parseVisualOverrides(childParams);
2245
+ const childOriginOverrides = parseTransformOrigin(childParams);
2246
+ const childBase = {
2247
+ id: childId,
2248
+ type: childType,
2249
+ name: childParams.name || `${childType.charAt(0).toUpperCase() + childType.slice(1)} Layer`,
2250
+ visible: true,
2251
+ locked: false,
2252
+ ...parseFlexChildProps(childParams),
2253
+ ...childVisualOverrides,
2254
+ transform: {
2255
+ x: 0,
2256
+ y: 0,
2257
+ // Shapes store intrinsic size in geometry fields; keep transform scale at 1.
2258
+ width: isChildShapeType ? 1 : cssPercentToSize(childParams.width, 100),
2259
+ height: isChildShapeType ? 1 : cssPercentToSize(childParams.height, 100),
2260
+ rotation: 0,
2261
+ opacity: normalizeOpacity(childParams.opacity, 1),
2262
+ ...childOriginOverrides,
2263
+ },
2264
+ };
2265
+ if (childType === 'text') {
2266
+ const frameWidthNormalized = parseNormalizedFrameDimension(childParams.frameWidth);
2267
+ const frameHeightNormalized = parseNormalizedFrameDimension(childParams.frameHeight);
2268
+ const frameWidthPxFromNormalized = frameWidthNormalized !== undefined
2269
+ ? textNormalizedToPx(frameWidthNormalized, artboardAspect, 'width')
2270
+ : undefined;
2271
+ const frameHeightPxFromNormalized = frameHeightNormalized !== undefined
2272
+ ? textNormalizedToPx(frameHeightNormalized, artboardAspect, 'height')
2273
+ : undefined;
2274
+ // All text is frame text
2275
+ let frameWidthPx = typeof childParams.frameWidthPx === 'number' ? childParams.frameWidthPx : frameWidthPxFromNormalized;
2276
+ const frameHeightPx = typeof childParams.frameHeightPx === 'number' ? childParams.frameHeightPx : frameHeightPxFromNormalized;
2277
+ const textBoxWidth = typeof childParams.textBoxWidth === 'number' ? childParams.textBoxWidth : undefined;
2278
+ const childFontSize = childParams.fontSize || 72;
2279
+ // Default frameWidthPx for group text children (same as add_layer)
2280
+ if (frameWidthPx == null && textBoxWidth == null) {
2281
+ const defaultFrameWidthNormalized = childFontSize >= 72 ? 0.88 : 0.78;
2282
+ frameWidthPx = textNormalizedToPx(defaultFrameWidthNormalized, artboardAspect, 'width');
2283
+ }
2284
+ childLayers.push({
2285
+ ...childBase,
2286
+ type: 'text',
2287
+ content: childParams.content || 'Text',
2288
+ textType: 'frame',
2289
+ fontFamily: normalizeFontFamily(childParams.fontFamily),
2290
+ fontSize: childFontSize,
2291
+ fontWeight: normalizeFontWeight(childParams.fontWeight),
2292
+ color: childParams.color || '#ffffff',
2293
+ textAlign: childParams.textAlign || 'center',
2294
+ ...(childParams.verticalAlign ? { verticalAlign: childParams.verticalAlign } : {}),
2295
+ letterSpacing: childParams.letterSpacing ?? 0,
2296
+ lineHeight: childParams.lineHeight ?? 1.2,
2297
+ textTransformMode: 'box',
2298
+ ...(textBoxWidth != null ? { textBoxWidth } : {}),
2299
+ frameWidthPx,
2300
+ ...(typeof frameHeightPx === 'number' ? { frameHeightPx } : {}),
2301
+ ...(childParams.autoSize ? { autoSize: childParams.autoSize } : { autoSize: 'height' }),
2302
+ ...(parseTextTransform(childParams) ? { textTransform: parseTextTransform(childParams) } : {}),
2303
+ ...parseTextDecoration(childParams),
2304
+ });
2305
+ }
2306
+ else if (childType === 'image') {
2307
+ if (!childParams.mediaUrl)
2308
+ continue;
2309
+ childLayers.push({
2310
+ ...childBase,
2311
+ type: 'image',
2312
+ mediaUrl: childParams.mediaUrl,
2313
+ objectFit: childParams.objectFit || 'cover',
2314
+ brightness: 1,
2315
+ contrast: 1,
2316
+ saturation: 1,
2317
+ });
2318
+ }
2319
+ else if (childType === 'video') {
2320
+ if (!childParams.mediaUrl)
2321
+ continue;
2322
+ childLayers.push({
2323
+ ...childBase,
2324
+ type: 'video',
2325
+ mediaUrl: childParams.mediaUrl,
2326
+ objectFit: childParams.objectFit || 'cover',
2327
+ brightness: 1,
2328
+ contrast: 1,
2329
+ saturation: 1,
2330
+ loop: true,
2331
+ playbackSpeed: 1,
2332
+ });
2333
+ }
2334
+ else if (childType === 'rectangle') {
2335
+ const fillColor = normalizeHexColor(childParams.fillColor) || '#3b82f6';
2336
+ const fillOpacity = normalizeOpacity(childParams.fillOpacity, 1);
2337
+ const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2338
+ const gradientFill = buildFillFromParams(childParams);
2339
+ childLayers.push({
2340
+ ...childBase,
2341
+ type: 'rectangle',
2342
+ style: {
2343
+ fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2344
+ fillType: 'solid',
2345
+ fillColor,
2346
+ fillOpacity,
2347
+ strokeColor,
2348
+ strokeWidth: normalizeStrokeWidth(childParams.strokeWidth, 0),
2349
+ strokeOpacity: normalizeOpacity(childParams.strokeOpacity, 1),
2350
+ strokeCap: 'round',
2351
+ strokeJoin: 'round',
2352
+ },
2353
+ shapeWidth: cssPercentToSize(childParams.width, 20),
2354
+ shapeHeight: cssPercentToSize(childParams.height, 20),
2355
+ cornerRadius: normalizeCornerRadius(childParams.cornerRadius, 0),
2356
+ ...(parseCornerRadii(childParams) ? { cornerRadii: parseCornerRadii(childParams) } : {}),
2357
+ });
2358
+ }
2359
+ else if (childType === 'ellipse') {
2360
+ const fillColor = normalizeHexColor(childParams.fillColor) || '#3b82f6';
2361
+ const fillOpacity = normalizeOpacity(childParams.fillOpacity, 1);
2362
+ const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2363
+ const w = cssPercentToSize(childParams.width, 20);
2364
+ const h = cssPercentToSize(childParams.height, 20);
2365
+ const gradientFill = buildFillFromParams(childParams);
2366
+ childLayers.push({
2367
+ ...childBase,
2368
+ type: 'ellipse',
2369
+ style: {
2370
+ fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2371
+ fillType: 'solid',
2372
+ fillColor,
2373
+ fillOpacity,
2374
+ strokeColor,
2375
+ strokeWidth: normalizeStrokeWidth(childParams.strokeWidth, 0),
2376
+ strokeOpacity: normalizeOpacity(childParams.strokeOpacity, 1),
2377
+ strokeCap: 'round',
2378
+ strokeJoin: 'round',
2379
+ },
2380
+ radiusX: w / 2,
2381
+ radiusY: h / 2,
2382
+ });
2383
+ }
2384
+ else if (childType === 'polygon') {
2385
+ const fillColor = normalizeHexColor(childParams.fillColor) || '#3b82f6';
2386
+ const fillOpacity = normalizeOpacity(childParams.fillOpacity, 1);
2387
+ const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2388
+ const w = cssPercentToSize(childParams.width, 20);
2389
+ const h = cssPercentToSize(childParams.height, 20);
2390
+ const gradientFill = buildFillFromParams(childParams);
2391
+ childLayers.push({
2392
+ ...childBase,
2393
+ type: 'polygon',
2394
+ style: {
2395
+ fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2396
+ fillType: 'solid',
2397
+ fillColor,
2398
+ fillOpacity,
2399
+ strokeColor,
2400
+ strokeWidth: normalizeStrokeWidth(childParams.strokeWidth, 0),
2401
+ strokeOpacity: normalizeOpacity(childParams.strokeOpacity, 1),
2402
+ strokeCap: 'round',
2403
+ strokeJoin: 'round',
2404
+ },
2405
+ radius: Math.min(w, h) / 2,
2406
+ sides: normalizePositiveInt(childParams.sides, 6, 3, 32),
2407
+ cornerRadius: normalizeCornerRadius(childParams.cornerRadius, 0),
2408
+ });
2409
+ }
2410
+ else if (childType === 'star') {
2411
+ const fillColor = normalizeHexColor(childParams.fillColor) || '#3b82f6';
2412
+ const fillOpacity = normalizeOpacity(childParams.fillOpacity, 1);
2413
+ const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2414
+ const w = cssPercentToSize(childParams.width, 20);
2415
+ const h = cssPercentToSize(childParams.height, 20);
2416
+ const gradientFill = buildFillFromParams(childParams);
2417
+ childLayers.push({
2418
+ ...childBase,
2419
+ type: 'star',
2420
+ style: {
2421
+ fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2422
+ fillType: 'solid',
2423
+ fillColor,
2424
+ fillOpacity,
2425
+ strokeColor,
2426
+ strokeWidth: normalizeStrokeWidth(childParams.strokeWidth, 0),
2427
+ strokeOpacity: normalizeOpacity(childParams.strokeOpacity, 1),
2428
+ strokeCap: 'round',
2429
+ strokeJoin: 'round',
2430
+ },
2431
+ outerRadius: Math.min(w, h) / 2,
2432
+ innerRadiusRatio: clamp(parseNumericInput(childParams.innerRadiusRatio), 0.05, 0.95, 0.5),
2433
+ points: normalizePositiveInt(childParams.points, 5, 3, 32),
2434
+ cornerRadius: normalizeCornerRadius(childParams.cornerRadius, 0),
2435
+ });
2436
+ }
2437
+ else if (childType === 'line') {
2438
+ const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2439
+ const strokeWidth = normalizeStrokeWidth(childParams.strokeWidth, 0.005);
2440
+ const strokeOpacity = normalizeOpacity(childParams.strokeOpacity, 1);
2441
+ const halfW = clamp(parseNumericInput(childParams.width), 0.01, 1, 0.6);
2442
+ const halfH = clamp(parseNumericInput(childParams.height), 0, 1, 0);
2443
+ const startX = cssPercentToX(childParams.startX, (-halfW + 1) * 50);
2444
+ const endX = cssPercentToX(childParams.endX, (halfW + 1) * 50);
2445
+ const startY = cssPercentToY(childParams.startY, (1 + halfH) * 50);
2446
+ const endY = cssPercentToY(childParams.endY, (1 - halfH) * 50);
2447
+ childLayers.push({
2448
+ ...childBase,
2449
+ type: 'line',
2450
+ style: {
2451
+ strokeColor,
2452
+ strokeWidth,
2453
+ strokeOpacity,
2454
+ strokeCap: 'round',
2455
+ strokeJoin: 'round',
2456
+ },
2457
+ startX,
2458
+ startY,
2459
+ endX,
2460
+ endY,
2461
+ });
2462
+ }
2463
+ }
2464
+ if (childLayers.length === 0) {
2465
+ return {
2466
+ content: [{ type: 'text', text: 'Error: No valid child layers could be created' }],
2467
+ };
2468
+ }
2469
+ // Build padding — convert from CSS % to internal normalized values
2470
+ const uniformPaddingPct = params.padding ?? 0;
2471
+ const uniformPadding = cssPercentToSize(uniformPaddingPct, 0);
2472
+ const groupPadding = {
2473
+ top: cssPercentToSize(params.paddingTop ?? uniformPaddingPct, 0),
2474
+ right: cssPercentToSize(params.paddingRight ?? uniformPaddingPct, 0),
2475
+ bottom: cssPercentToSize(params.paddingBottom ?? uniformPaddingPct, 0),
2476
+ left: cssPercentToSize(params.paddingLeft ?? uniformPaddingPct, 0),
2477
+ };
2478
+ const layoutMode = params.layoutMode || 'column';
2479
+ const widthMode = params.widthMode ?? (params.width !== undefined ? 'fixed' : 'hug');
2480
+ const heightMode = params.heightMode ?? (params.height !== undefined ? 'fixed' : 'hug');
2481
+ const baseWidth = cssPercentToSize(params.width, 60);
2482
+ const baseHeight = cssPercentToSize(params.height, 40);
2483
+ const groupLayer = {
2484
+ id: groupId,
2485
+ type: 'group',
2486
+ name: params.name || 'Group',
2487
+ visible: true,
2488
+ locked: false,
2489
+ transform: {
2490
+ x: cssPercentToX(params.x, 50),
2491
+ y: cssPercentToY(params.y, 50),
2492
+ width: 1,
2493
+ height: 1,
2494
+ rotation: params.rotation ?? 0,
2495
+ opacity: normalizeOpacity(params.opacity, 1),
2496
+ },
2497
+ children: childLayers,
2498
+ expanded: true,
2499
+ baseWidth,
2500
+ baseHeight,
2501
+ layoutMode,
2502
+ widthMode,
2503
+ heightMode,
2504
+ gap: cssPercentToSize(params.gap, 2),
2505
+ justifyContent: params.justifyContent ?? 'start',
2506
+ alignItems: params.alignItems ?? 'center',
2507
+ wrap: params.wrap ?? false,
2508
+ padding: groupPadding,
2509
+ ...(params.fillColor ? { fillColor: params.fillColor } : {}),
2510
+ ...(params.fillOpacity !== undefined ? { fillOpacity: params.fillOpacity } : {}),
2511
+ ...(params.cornerRadius !== undefined ? { cornerRadius: params.cornerRadius } : {}),
2512
+ };
2513
+ currentPoster.layers.push(groupLayer);
2514
+ persistState();
2515
+ return {
2516
+ content: [
2517
+ {
2518
+ type: 'text',
2519
+ text: JSON.stringify({
2520
+ success: true,
2521
+ message: `Group added with ${childLayers.length} children (${groupLayer.layoutMode} layout)`,
2522
+ group: groupLayer,
2523
+ totalLayers: currentPoster.layers.length,
2524
+ }, null, 2),
2525
+ },
2526
+ ],
2527
+ };
2528
+ }
2529
+ case 'modify_layer': {
2530
+ ensurePoster();
919
2531
  const layerId = params.layerId;
920
2532
  if (!layerId) {
921
2533
  return {
@@ -929,11 +2541,28 @@ async function handleStateTool(name, args) {
929
2541
  };
930
2542
  }
931
2543
  const layer = currentPoster.layers[layerIndex];
2544
+ const isShapeType = ['rectangle', 'ellipse', 'polygon', 'star', 'line'].includes(layer.type);
932
2545
  // Update transform properties
933
- const transformKeys = ['x', 'y', 'width', 'height', 'rotation', 'opacity'];
2546
+ const transformKeys = isShapeType
2547
+ ? ['x', 'y', 'rotation', 'opacity']
2548
+ : ['x', 'y', 'width', 'height', 'rotation', 'opacity'];
934
2549
  for (const key of transformKeys) {
935
2550
  if (params[key] !== undefined) {
936
- layer.transform[key] = params[key];
2551
+ if (key === 'x') {
2552
+ layer.transform[key] = cssPercentToX(params[key], (layer.transform[key] + 1) * 50);
2553
+ }
2554
+ else if (key === 'y') {
2555
+ layer.transform[key] = cssPercentToY(params[key], (1 - layer.transform[key]) * 50);
2556
+ }
2557
+ else if (key === 'opacity') {
2558
+ layer.transform[key] = normalizeOpacity(params[key], layer.transform[key]);
2559
+ }
2560
+ else if (key === 'width' || key === 'height') {
2561
+ layer.transform[key] = cssPercentToSize(params[key], layer.transform[key] * 100);
2562
+ }
2563
+ else {
2564
+ layer.transform[key] = params[key];
2565
+ }
937
2566
  }
938
2567
  }
939
2568
  // Update base properties
@@ -943,9 +2572,46 @@ async function handleStateTool(name, args) {
943
2572
  layer.locked = params.locked;
944
2573
  if (params.name !== undefined)
945
2574
  layer.name = params.name;
2575
+ // Update flex-child properties
2576
+ const flexProps = parseFlexChildProps(params);
2577
+ if (flexProps.positioning !== undefined)
2578
+ layer.positioning = flexProps.positioning;
2579
+ if (flexProps.flexWidthMode !== undefined)
2580
+ layer.flexWidthMode = flexProps.flexWidthMode;
2581
+ if (flexProps.flexHeightMode !== undefined)
2582
+ layer.flexHeightMode = flexProps.flexHeightMode;
2583
+ if (flexProps.alignSelf !== undefined)
2584
+ layer.alignSelf = flexProps.alignSelf;
2585
+ if (flexProps.flexGrow !== undefined)
2586
+ layer.flexGrow = flexProps.flexGrow;
2587
+ // Update visual props (shadow, blendMode, margin, min/max, transform origin — all layer types)
2588
+ const shadow = parseShadowParams(params);
2589
+ if (shadow)
2590
+ layer.shadow = shadow;
2591
+ const blendMode = parseBlendMode(params);
2592
+ if (blendMode !== undefined)
2593
+ layer.blendMode = blendMode;
2594
+ const margin = parseMarginParams(params);
2595
+ if (margin)
2596
+ layer.margin = margin;
2597
+ const constraints = parseMinMaxConstraints(params);
2598
+ if (constraints.minWidth !== undefined)
2599
+ layer.minWidth = constraints.minWidth;
2600
+ if (constraints.maxWidth !== undefined)
2601
+ layer.maxWidth = constraints.maxWidth;
2602
+ if (constraints.minHeight !== undefined)
2603
+ layer.minHeight = constraints.minHeight;
2604
+ if (constraints.maxHeight !== undefined)
2605
+ layer.maxHeight = constraints.maxHeight;
2606
+ const originOverrides = parseTransformOrigin(params);
2607
+ if (originOverrides.originX !== undefined)
2608
+ layer.transform.originX = originOverrides.originX;
2609
+ if (originOverrides.originY !== undefined)
2610
+ layer.transform.originY = originOverrides.originY;
946
2611
  // Update text layer properties
947
2612
  if (layer.type === 'text') {
948
2613
  const textLayer = layer;
2614
+ const artboardAspect = getArtboardAspect(currentPoster.canvas.aspectRatio);
949
2615
  if (params.content !== undefined)
950
2616
  textLayer.content = params.content;
951
2617
  if (params.fontFamily !== undefined)
@@ -958,10 +2624,46 @@ async function handleStateTool(name, args) {
958
2624
  textLayer.color = params.color;
959
2625
  if (params.textAlign !== undefined)
960
2626
  textLayer.textAlign = params.textAlign;
2627
+ if (params.verticalAlign !== undefined)
2628
+ textLayer.verticalAlign = params.verticalAlign;
961
2629
  if (params.letterSpacing !== undefined)
962
2630
  textLayer.letterSpacing = params.letterSpacing;
963
2631
  if (params.lineHeight !== undefined)
964
2632
  textLayer.lineHeight = params.lineHeight;
2633
+ // All text is frame text — silently ignore any 'point'/'scale' requests
2634
+ textLayer.textType = 'frame';
2635
+ textLayer.textTransformMode = 'box';
2636
+ if (params.textBoxWidth !== undefined)
2637
+ textLayer.textBoxWidth = params.textBoxWidth;
2638
+ if (params.frameWidthPx !== undefined)
2639
+ textLayer.frameWidthPx = params.frameWidthPx;
2640
+ if (params.frameHeightPx !== undefined)
2641
+ textLayer.frameHeightPx = params.frameHeightPx;
2642
+ if (params.autoSize !== undefined)
2643
+ textLayer.autoSize = params.autoSize;
2644
+ // Text CSS properties
2645
+ const tt = parseTextTransform(params);
2646
+ if (tt !== undefined)
2647
+ textLayer.textTransform = tt;
2648
+ else if (params.textTransform === 'none')
2649
+ textLayer.textTransform = undefined;
2650
+ const td = parseTextDecoration(params);
2651
+ if (td.textDecoration !== undefined)
2652
+ textLayer.textDecoration = td.textDecoration;
2653
+ else if (params.textDecoration === 'none')
2654
+ textLayer.textDecoration = undefined;
2655
+ if (td.textDecorationColor !== undefined)
2656
+ textLayer.textDecorationColor = td.textDecorationColor;
2657
+ if (td.textDecorationThickness !== undefined)
2658
+ textLayer.textDecorationThickness = td.textDecorationThickness;
2659
+ const frameWidthNormalized = parseNormalizedFrameDimension(params.frameWidth);
2660
+ if (frameWidthNormalized !== undefined && params.frameWidthPx === undefined) {
2661
+ textLayer.frameWidthPx = textNormalizedToPx(frameWidthNormalized, artboardAspect, 'width');
2662
+ }
2663
+ const frameHeightNormalized = parseNormalizedFrameDimension(params.frameHeight);
2664
+ if (frameHeightNormalized !== undefined && params.frameHeightPx === undefined) {
2665
+ textLayer.frameHeightPx = textNormalizedToPx(frameHeightNormalized, artboardAspect, 'height');
2666
+ }
965
2667
  // Update animation settings
966
2668
  if (params.animationType !== undefined) {
967
2669
  if (params.animationType === 'none') {
@@ -1095,6 +2797,177 @@ async function handleStateTool(name, args) {
1095
2797
  if (params.objectFit !== undefined)
1096
2798
  mediaLayer.objectFit = params.objectFit;
1097
2799
  }
2800
+ // Update group layout properties
2801
+ if (layer.type === 'group') {
2802
+ const groupLayer = layer;
2803
+ if (params.layoutMode !== undefined)
2804
+ groupLayer.layoutMode = params.layoutMode;
2805
+ if (params.widthMode !== undefined)
2806
+ groupLayer.widthMode = params.widthMode;
2807
+ if (params.heightMode !== undefined)
2808
+ groupLayer.heightMode = params.heightMode;
2809
+ if (params.gap !== undefined)
2810
+ groupLayer.gap = cssPercentToSize(params.gap, (groupLayer.gap ?? 0.02) * 100);
2811
+ if (params.justifyContent !== undefined)
2812
+ groupLayer.justifyContent = params.justifyContent;
2813
+ if (params.alignItems !== undefined)
2814
+ groupLayer.alignItems = params.alignItems;
2815
+ if (params.wrap !== undefined)
2816
+ groupLayer.wrap = params.wrap;
2817
+ if (params.fillColor !== undefined)
2818
+ groupLayer.fillColor = params.fillColor;
2819
+ if (params.fillOpacity !== undefined)
2820
+ groupLayer.fillOpacity = params.fillOpacity;
2821
+ if (params.cornerRadius !== undefined)
2822
+ groupLayer.cornerRadius = params.cornerRadius;
2823
+ const groupCornerRadii = parseCornerRadii(params);
2824
+ if (groupCornerRadii)
2825
+ groupLayer.cornerRadii = groupCornerRadii;
2826
+ // Padding: uniform or individual overrides (CSS % to normalized)
2827
+ if (params.padding !== undefined || params.paddingTop !== undefined || params.paddingRight !== undefined || params.paddingBottom !== undefined || params.paddingLeft !== undefined) {
2828
+ const existing = groupLayer.padding || { top: 0, right: 0, bottom: 0, left: 0 };
2829
+ const uniformPct = params.padding !== undefined ? parseNumericInput(params.padding) : undefined;
2830
+ const uniform = uniformPct !== undefined ? cssPercentToSize(uniformPct, 0) : undefined;
2831
+ groupLayer.padding = {
2832
+ top: params.paddingTop !== undefined ? cssPercentToSize(params.paddingTop, 0) : uniform ?? existing.top,
2833
+ right: params.paddingRight !== undefined ? cssPercentToSize(params.paddingRight, 0) : uniform ?? existing.right,
2834
+ bottom: params.paddingBottom !== undefined ? cssPercentToSize(params.paddingBottom, 0) : uniform ?? existing.bottom,
2835
+ left: params.paddingLeft !== undefined ? cssPercentToSize(params.paddingLeft, 0) : uniform ?? existing.left,
2836
+ };
2837
+ }
2838
+ // Allow updating baseWidth/baseHeight via width/height params
2839
+ if (params.width !== undefined) {
2840
+ groupLayer.baseWidth = cssPercentToSize(params.width, groupLayer.baseWidth * 100);
2841
+ if (params.widthMode === undefined) {
2842
+ groupLayer.widthMode = 'fixed';
2843
+ }
2844
+ }
2845
+ if (params.height !== undefined) {
2846
+ groupLayer.baseHeight = cssPercentToSize(params.height, groupLayer.baseHeight * 100);
2847
+ if (params.heightMode === undefined) {
2848
+ groupLayer.heightMode = 'fixed';
2849
+ }
2850
+ }
2851
+ }
2852
+ // Update shape properties (geometry + style)
2853
+ if (isShapeType) {
2854
+ const anyLayer = layer;
2855
+ // Style (common)
2856
+ if (!anyLayer.style)
2857
+ anyLayer.style = {};
2858
+ if (params.fillColor !== undefined) {
2859
+ const existing = typeof anyLayer.style.fillColor === 'string' ? anyLayer.style.fillColor : '#3b82f6';
2860
+ anyLayer.style.fillColor = normalizeHexColor(params.fillColor) || existing;
2861
+ }
2862
+ if (params.fillOpacity !== undefined) {
2863
+ const existing = asFiniteNumber(anyLayer.style.fillOpacity, 1);
2864
+ anyLayer.style.fillOpacity = normalizeOpacity(params.fillOpacity, existing);
2865
+ }
2866
+ if (params.strokeColor !== undefined) {
2867
+ const existing = typeof anyLayer.style.strokeColor === 'string' ? anyLayer.style.strokeColor : '#000000';
2868
+ anyLayer.style.strokeColor = normalizeHexColor(params.strokeColor) || existing;
2869
+ }
2870
+ if (params.strokeWidth !== undefined) {
2871
+ const existing = asFiniteNumber(anyLayer.style.strokeWidth, 0);
2872
+ anyLayer.style.strokeWidth = normalizeStrokeWidth(params.strokeWidth, existing);
2873
+ }
2874
+ if (params.strokeOpacity !== undefined) {
2875
+ const existing = asFiniteNumber(anyLayer.style.strokeOpacity, 1);
2876
+ anyLayer.style.strokeOpacity = normalizeOpacity(params.strokeOpacity, existing);
2877
+ }
2878
+ // Gradient fill update
2879
+ const modifyGradientFill = buildFillFromParams(params);
2880
+ if (modifyGradientFill) {
2881
+ anyLayer.style.fill = modifyGradientFill;
2882
+ anyLayer.style.fillType = 'solid';
2883
+ }
2884
+ else if (params.fillColor !== undefined || params.fillOpacity !== undefined) {
2885
+ // Sync style.fill as solid when only color/opacity changed
2886
+ const currentFillColor = typeof anyLayer.style.fillColor === 'string' ? anyLayer.style.fillColor : '#3b82f6';
2887
+ const currentFillOpacity = asFiniteNumber(anyLayer.style.fillOpacity, 1);
2888
+ anyLayer.style.fill = { type: 'solid', color: currentFillColor, opacity: currentFillOpacity };
2889
+ anyLayer.style.fillType = 'solid';
2890
+ }
2891
+ if (anyLayer.style.fillType === undefined && layer.type !== 'line')
2892
+ anyLayer.style.fillType = 'solid';
2893
+ if (anyLayer.style.strokeCap === undefined)
2894
+ anyLayer.style.strokeCap = 'round';
2895
+ if (anyLayer.style.strokeJoin === undefined)
2896
+ anyLayer.style.strokeJoin = 'round';
2897
+ if (layer.type === 'rectangle') {
2898
+ if (params.width !== undefined)
2899
+ anyLayer.shapeWidth = cssPercentToSize(params.width, asFiniteNumber(anyLayer.shapeWidth, 0.2) * 100);
2900
+ if (params.height !== undefined)
2901
+ anyLayer.shapeHeight = cssPercentToSize(params.height, asFiniteNumber(anyLayer.shapeHeight, 0.2) * 100);
2902
+ if (params.cornerRadius !== undefined)
2903
+ anyLayer.cornerRadius = normalizeCornerRadius(params.cornerRadius, asFiniteNumber(anyLayer.cornerRadius, 0));
2904
+ const rectCornerRadii = parseCornerRadii(params);
2905
+ if (rectCornerRadii)
2906
+ anyLayer.cornerRadii = rectCornerRadii;
2907
+ }
2908
+ else if (layer.type === 'ellipse') {
2909
+ if (params.width !== undefined)
2910
+ anyLayer.radiusX = cssPercentToSize(params.width, asFiniteNumber(anyLayer.radiusX, 0.1) * 2 * 100) / 2;
2911
+ if (params.height !== undefined)
2912
+ anyLayer.radiusY = cssPercentToSize(params.height, asFiniteNumber(anyLayer.radiusY, 0.1) * 2 * 100) / 2;
2913
+ }
2914
+ else if (layer.type === 'polygon') {
2915
+ if (params.width !== undefined || params.height !== undefined) {
2916
+ const baseRadius = asFiniteNumber(anyLayer.radius, 0.1);
2917
+ const w = params.width !== undefined ? cssPercentToSize(params.width, baseRadius * 2 * 100) : undefined;
2918
+ const h = params.height !== undefined ? cssPercentToSize(params.height, baseRadius * 2 * 100) : undefined;
2919
+ const size = Math.min(w ?? baseRadius * 2, h ?? baseRadius * 2);
2920
+ anyLayer.radius = size / 2;
2921
+ }
2922
+ if (params.sides !== undefined)
2923
+ anyLayer.sides = normalizePositiveInt(params.sides, asFiniteNumber(anyLayer.sides, 6), 3, 64);
2924
+ if (params.cornerRadius !== undefined)
2925
+ anyLayer.cornerRadius = normalizeCornerRadius(params.cornerRadius, asFiniteNumber(anyLayer.cornerRadius, 0));
2926
+ }
2927
+ else if (layer.type === 'star') {
2928
+ if (params.width !== undefined || params.height !== undefined) {
2929
+ const baseOuter = asFiniteNumber(anyLayer.outerRadius, 0.1);
2930
+ const w = params.width !== undefined ? cssPercentToSize(params.width, baseOuter * 2 * 100) : undefined;
2931
+ const h = params.height !== undefined ? cssPercentToSize(params.height, baseOuter * 2 * 100) : undefined;
2932
+ const size = Math.min(w ?? baseOuter * 2, h ?? baseOuter * 2);
2933
+ anyLayer.outerRadius = size / 2;
2934
+ }
2935
+ if (params.innerRadiusRatio !== undefined) {
2936
+ anyLayer.innerRadiusRatio = clamp(parseNumericInput(params.innerRadiusRatio), 0.05, 0.95, asFiniteNumber(anyLayer.innerRadiusRatio, 0.5));
2937
+ }
2938
+ if (params.points !== undefined)
2939
+ anyLayer.points = normalizePositiveInt(params.points, asFiniteNumber(anyLayer.points, 5), 3, 64);
2940
+ if (params.cornerRadius !== undefined)
2941
+ anyLayer.cornerRadius = normalizeCornerRadius(params.cornerRadius, asFiniteNumber(anyLayer.cornerRadius, 0));
2942
+ }
2943
+ else if (layer.type === 'line') {
2944
+ if (params.startX !== undefined)
2945
+ anyLayer.startX = cssPercentToX(params.startX, (asFiniteNumber(anyLayer.startX, -0.6) + 1) * 50);
2946
+ if (params.endX !== undefined)
2947
+ anyLayer.endX = cssPercentToX(params.endX, (asFiniteNumber(anyLayer.endX, 0.6) + 1) * 50);
2948
+ if (params.startY !== undefined)
2949
+ anyLayer.startY = cssPercentToY(params.startY, (1 - asFiniteNumber(anyLayer.startY, 0)) * 50);
2950
+ if (params.endY !== undefined)
2951
+ anyLayer.endY = cssPercentToY(params.endY, (1 - asFiniteNumber(anyLayer.endY, 0)) * 50);
2952
+ // Convenience sizing: adjust around 0 when width/height provided without explicit endpoints.
2953
+ if ((params.width !== undefined || params.height !== undefined) && params.startX === undefined && params.endX === undefined && params.startY === undefined && params.endY === undefined) {
2954
+ const prevHalfW = Math.max(0.01, Math.abs(asFiniteNumber(anyLayer.endX, 0.6) - asFiniteNumber(anyLayer.startX, -0.6)) / 2);
2955
+ const prevHalfH = Math.abs(asFiniteNumber(anyLayer.endY, 0) - asFiniteNumber(anyLayer.startY, 0)) / 2;
2956
+ const halfW = clamp(parseNumericInput(params.width), 0.01, 1, prevHalfW);
2957
+ const halfH = clamp(parseNumericInput(params.height), 0, 1, prevHalfH);
2958
+ anyLayer.startX = -halfW;
2959
+ anyLayer.endX = halfW;
2960
+ anyLayer.startY = -halfH;
2961
+ anyLayer.endY = halfH;
2962
+ }
2963
+ }
2964
+ // Keep transform scale at 1 for deterministic geometry sizing.
2965
+ if (params.width !== undefined || params.height !== undefined || params.startX !== undefined || params.startY !== undefined || params.endX !== undefined || params.endY !== undefined) {
2966
+ layer.transform.width = 1;
2967
+ layer.transform.height = 1;
2968
+ }
2969
+ }
2970
+ persistState();
1098
2971
  return {
1099
2972
  content: [
1100
2973
  {
@@ -1109,11 +2982,7 @@ async function handleStateTool(name, args) {
1109
2982
  };
1110
2983
  }
1111
2984
  case 'apply_effect': {
1112
- if (!currentPoster) {
1113
- return {
1114
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1115
- };
1116
- }
2985
+ ensurePoster();
1117
2986
  const effectId = params.effectId;
1118
2987
  if (!effectId) {
1119
2988
  return {
@@ -1122,6 +2991,7 @@ async function handleStateTool(name, args) {
1122
2991
  }
1123
2992
  if (effectId === 'none') {
1124
2993
  delete currentPoster.effect;
2994
+ persistState();
1125
2995
  return {
1126
2996
  content: [
1127
2997
  {
@@ -1188,12 +3058,15 @@ async function handleStateTool(name, args) {
1188
3058
  };
1189
3059
  // Dither has BUILT-IN palette and bloom (WebGL pipeline)
1190
3060
  // These don't use post-processes - settings are on the effect itself
3061
+ const ditherPaletteId = params.paletteId ?? null;
3062
+ const ditherDefaultColors = ['#000000', '#ffffff'];
3063
+ // When paletteId is set, resolve to actual colors so the URL encodes them correctly
3064
+ const ditherColors = params.colors ?? resolvePaletteColors(ditherPaletteId, ditherDefaultColors);
1191
3065
  effect.dither = {
1192
3066
  pattern: patternMap[effectId] || 'floydSteinberg',
1193
3067
  pixelation: clamp(params.pixelation, 1, 20, 3),
1194
- // Palette: use paletteId for built-in palette, or custom colors array
1195
- paletteId: params.paletteId ?? null,
1196
- colors: params.colors ?? ['#000000', '#ffffff'],
3068
+ paletteId: ditherPaletteId,
3069
+ colors: ditherColors,
1197
3070
  brightness: clamp(params.brightness, 0, 2, 1),
1198
3071
  contrast: clamp(params.contrast, 0.5, 2, 1.2),
1199
3072
  threshold: 1.0,
@@ -1250,12 +3123,12 @@ async function handleStateTool(name, args) {
1250
3123
  }
1251
3124
  else if (settingsKey === 'chromatic') {
1252
3125
  effect.chromatic = {
1253
- shape: 'dot',
3126
+ shape: 'square',
1254
3127
  radius: 4,
1255
3128
  rotateR: 15,
1256
3129
  rotateG: 75,
1257
3130
  rotateB: 0,
1258
- scatter: 0,
3131
+ scatter: 1,
1259
3132
  blending: 1,
1260
3133
  blendingMode: 'linear',
1261
3134
  greyscale: false,
@@ -1273,12 +3146,12 @@ async function handleStateTool(name, args) {
1273
3146
  // Digital glitch defaults
1274
3147
  blockSize: 0.5,
1275
3148
  displacement: 0.5,
1276
- blockOpacity: 1.0,
3149
+ blockOpacity: 0,
1277
3150
  colorSplit: 0.5,
1278
3151
  lineTear: 0.5,
1279
3152
  pixelate: 0.3,
1280
3153
  // Weird glitch defaults
1281
- glitchChance: 0.2,
3154
+ glitchChance: 0.66,
1282
3155
  glitchSpeed: 7.0,
1283
3156
  sliceDensity: 14.0,
1284
3157
  sliceStrength: 0.38,
@@ -1366,6 +3239,7 @@ async function handleStateTool(name, args) {
1366
3239
  };
1367
3240
  }
1368
3241
  currentPoster.effect = effect;
3242
+ persistState();
1369
3243
  return {
1370
3244
  content: [
1371
3245
  {
@@ -1380,11 +3254,7 @@ async function handleStateTool(name, args) {
1380
3254
  };
1381
3255
  }
1382
3256
  case 'add_postprocess': {
1383
- if (!currentPoster) {
1384
- return {
1385
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1386
- };
1387
- }
3257
+ ensurePoster();
1388
3258
  const ppType = params.type;
1389
3259
  if (!ppType) {
1390
3260
  return {
@@ -1430,6 +3300,18 @@ async function handleStateTool(name, args) {
1430
3300
  'decay', // light-beams
1431
3301
  'density', // light-beams
1432
3302
  'particleAmount', // light-beams
3303
+ // Blob tracking
3304
+ 'detection', // blob-tracking
3305
+ 'rectScale', // blob-tracking
3306
+ 'zoom', // blob-tracking
3307
+ 'invertChance', // blob-tracking
3308
+ 'animSpeed', // blob-tracking
3309
+ 'lineColor', // blob-tracking
3310
+ 'lineOpacity', // blob-tracking
3311
+ 'rectColor', // blob-tracking
3312
+ 'rectOpacity', // blob-tracking
3313
+ 'showLabels', // blob-tracking
3314
+ 'glow', // blob-tracking
1433
3315
  ];
1434
3316
  const allSettingKeys = [...commonKeys, ...additionalKeys];
1435
3317
  for (const key of allSettingKeys) {
@@ -1442,6 +3324,11 @@ async function handleStateTool(name, args) {
1442
3324
  settings.type = settings.blurType;
1443
3325
  delete settings.blurType;
1444
3326
  }
3327
+ // Palette post-process: resolve paletteId to colors if colors weren't explicitly provided
3328
+ if (ppType === 'palette' && settings.paletteId && !params.colors) {
3329
+ const resolved = resolvePaletteColors(settings.paletteId, ['#000000', '#ffffff']);
3330
+ settings.colors = resolved;
3331
+ }
1445
3332
  const postProcess = {
1446
3333
  id: ppId,
1447
3334
  type: ppType,
@@ -1449,6 +3336,7 @@ async function handleStateTool(name, args) {
1449
3336
  settings,
1450
3337
  };
1451
3338
  currentPoster.postProcesses.push(postProcess);
3339
+ persistState();
1452
3340
  // Build response with warning if dither is active
1453
3341
  const response = {
1454
3342
  success: true,
@@ -1471,11 +3359,7 @@ async function handleStateTool(name, args) {
1471
3359
  };
1472
3360
  }
1473
3361
  case 'remove_layer': {
1474
- if (!currentPoster) {
1475
- return {
1476
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1477
- };
1478
- }
3362
+ ensurePoster();
1479
3363
  const layerId = params.layerId;
1480
3364
  if (!layerId) {
1481
3365
  return {
@@ -1494,6 +3378,7 @@ async function handleStateTool(name, args) {
1494
3378
  };
1495
3379
  }
1496
3380
  currentPoster.layers.splice(layerIndex, 1);
3381
+ persistState();
1497
3382
  return {
1498
3383
  content: [
1499
3384
  {
@@ -1507,12 +3392,67 @@ async function handleStateTool(name, args) {
1507
3392
  ],
1508
3393
  };
1509
3394
  }
1510
- case 'get_state': {
1511
- if (!currentPoster) {
3395
+ case 'move_layer': {
3396
+ ensurePoster();
3397
+ const poster = currentPoster;
3398
+ const layerId = params.layerId;
3399
+ const direction = params.direction;
3400
+ if (!layerId) {
1512
3401
  return {
1513
- content: [{ type: 'text', text: 'No poster created yet. Use create_poster first.' }],
3402
+ content: [{ type: 'text', text: 'Error: layerId is required' }],
1514
3403
  };
1515
3404
  }
3405
+ const layerIndex = poster.layers.findIndex((l) => l.id === layerId);
3406
+ if (layerIndex < 0) {
3407
+ return {
3408
+ content: [{ type: 'text', text: `Error: Layer not found: ${layerId}` }],
3409
+ };
3410
+ }
3411
+ if (layerIndex === 0) {
3412
+ return {
3413
+ content: [{ type: 'text', text: 'Error: Cannot move background layer' }],
3414
+ };
3415
+ }
3416
+ const clampIndex = (index) => Math.max(1, Math.min(index, poster.layers.length - 1));
3417
+ const moveTo = (toIndex) => {
3418
+ const nextIndex = clampIndex(toIndex);
3419
+ if (nextIndex === layerIndex)
3420
+ return false;
3421
+ const [layer] = poster.layers.splice(layerIndex, 1);
3422
+ poster.layers.splice(nextIndex, 0, layer);
3423
+ return true;
3424
+ };
3425
+ let moved = false;
3426
+ if (direction === 'up')
3427
+ moved = moveTo(layerIndex + 1);
3428
+ else if (direction === 'down')
3429
+ moved = moveTo(layerIndex - 1);
3430
+ else if (direction === 'top')
3431
+ moved = moveTo(poster.layers.length - 1);
3432
+ else if (direction === 'bottom')
3433
+ moved = moveTo(1);
3434
+ else {
3435
+ return {
3436
+ content: [{ type: 'text', text: 'Error: direction must be one of: up, down, top, bottom' }],
3437
+ };
3438
+ }
3439
+ persistState();
3440
+ return {
3441
+ content: [
3442
+ {
3443
+ type: 'text',
3444
+ text: JSON.stringify({
3445
+ success: true,
3446
+ message: moved ? `Layer moved (${direction})` : `Layer already at ${direction}`,
3447
+ layerId,
3448
+ totalLayers: poster.layers.length,
3449
+ }, null, 2),
3450
+ },
3451
+ ],
3452
+ };
3453
+ }
3454
+ case 'get_state': {
3455
+ ensurePoster();
1516
3456
  return {
1517
3457
  content: [
1518
3458
  {