@efectoapp/mcp-server 0.1.21 → 0.1.23

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() {
@@ -401,6 +800,113 @@ function parseGradientStopsInput(input, fallbackStops) {
401
800
  .filter((stop) => stop !== null);
402
801
  return parsed.length >= MIN_GRADIENT_STOPS ? parsed : null;
403
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
+ };
404
910
  // Tool definitions
405
911
  exports.stateTools = [
406
912
  {
@@ -420,6 +926,84 @@ exports.stateTools = [
420
926
  description: 'Background color in hex format (e.g., "#1a1a1a")',
421
927
  default: '#1a1a1a',
422
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
+ },
423
1007
  },
424
1008
  required: [],
425
1009
  },
@@ -510,32 +1094,66 @@ exports.stateTools = [
510
1094
  properties: {
511
1095
  type: {
512
1096
  type: 'string',
513
- enum: ['text', 'image', 'video'],
1097
+ enum: ['text', 'image', 'video', 'rectangle', 'ellipse', 'polygon', 'star', 'line'],
514
1098
  description: 'Type of layer to add',
515
1099
  },
516
1100
  name: { type: 'string', description: 'Layer name' },
517
1101
  // Transform properties (normalized coordinates relative to canvas)
518
- x: { type: 'number', description: 'X position (-1 to 1, 0 is center, positive is right)', default: 0 },
519
- y: { type: 'number', description: 'Y position (-1 to 1, 0 is center, POSITIVE is UP/TOP, negative is down/bottom)', default: 0 },
520
- width: { type: 'number', description: 'Width (0-1 relative to canvas)', default: 1 },
521
- 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 },
522
1106
  rotation: { type: 'number', description: 'Rotation in degrees', default: 0 },
523
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)' },
524
1134
  // Text layer properties
525
1135
  content: { type: 'string', description: 'Text content (required for text layers)' },
526
- textType: { type: 'string', enum: ['point', 'frame'], description: 'Text authoring mode: "frame" for layout copy with wrapping (recommended), "point" for scalable text', default: 'frame' },
1136
+ textType: { type: 'string', enum: ['frame'], description: 'Text mode (always "frame" point text is deprecated)', default: 'frame' },
527
1137
  fontFamily: { type: 'string', description: 'Font family name', default: 'DM Sans' },
528
1138
  fontSize: { type: 'number', description: 'Font size in pixels', default: 72 },
529
1139
  fontWeight: { type: 'string', enum: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], description: 'Font weight', default: 'bold' },
530
1140
  color: { type: 'string', description: 'Text color in hex', default: '#ffffff' },
531
1141
  textAlign: { type: 'string', enum: ['left', 'center', 'right'], description: 'Text alignment', default: 'center' },
1142
+ verticalAlign: { type: 'string', enum: ['top', 'middle', 'bottom'], description: 'Vertical text alignment inside the frame. Defaults to "top" for frame text.' },
532
1143
  letterSpacing: { type: 'number', description: 'Letter spacing in pixels (negative for tighter, e.g., -1 to -2 for headlines)', default: 0 },
533
1144
  lineHeight: { type: 'number', description: 'Line height multiplier (0.9-1.0 for tight headlines, 1.3-1.5 for body)', default: 1.2 },
534
- textTransformMode: { type: 'string', enum: ['scale', 'box'], description: 'Text sizing mode: "box" for natural wrapping (recommended), "scale" to stretch to fit', default: 'box' },
1145
+ textTransformMode: { type: 'string', enum: ['box'], description: 'Text sizing mode (always "box" scale mode is deprecated)', default: 'box' },
535
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.' },
536
1149
  frameWidthPx: { type: 'number', description: 'Frame width in CSS pixels for frame text (resizing changes this, not fontSize)' },
537
1150
  frameHeightPx: { type: 'number', description: 'Frame height in CSS pixels for frame text' },
538
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' },
539
1157
  // Text animation properties (use list_text_animations to see all options)
540
1158
  animationType: {
541
1159
  type: 'string',
@@ -609,6 +1227,27 @@ exports.stateTools = [
609
1227
  // Image/Video properties
610
1228
  mediaUrl: { type: 'string', description: 'Media URL (required for image/video layers)' },
611
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)' },
612
1251
  },
613
1252
  required: ['type'],
614
1253
  },
@@ -623,22 +1262,22 @@ exports.stateTools = [
623
1262
  layoutMode: { type: 'string', enum: ['row', 'column'], description: 'Flex direction: "column" stacks vertically (default, most common), "row" places side by side', default: 'column' },
624
1263
  widthMode: { type: 'string', enum: ['hug', 'fixed'], description: 'Horizontal sizing mode: hug contents or fixed width' },
625
1264
  heightMode: { type: 'string', enum: ['hug', 'fixed'], description: 'Vertical sizing mode: hug contents or fixed height' },
626
- gap: { type: 'number', description: 'Space between children (like CSS gap). 0.02=small, 0.04=medium, 0.06=large', default: 0.02 },
1265
+ gap: { type: 'number', description: 'Space between children as % of canvas (2=small, 4=medium, 6=large). Negative values overlap children.', default: 2 },
627
1266
  justifyContent: { type: 'string', enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], description: 'Main-axis alignment (like CSS justify-content)', default: 'start' },
628
1267
  alignItems: { type: 'string', enum: ['start', 'center', 'end'], description: 'Cross-axis alignment (like CSS align-items): "start"=left, "center"=centered, "end"=right', default: 'center' },
629
1268
  wrap: { type: 'boolean', description: 'Wrap children to next line on overflow (like CSS flex-wrap)', default: false },
630
- padding: { type: 'number', description: 'Inner padding (like CSS padding)', default: 0 },
631
- paddingTop: { type: 'number', description: 'Top padding override' },
632
- paddingRight: { type: 'number', description: 'Right padding override' },
633
- paddingBottom: { type: 'number', description: 'Bottom padding override' },
634
- paddingLeft: { type: 'number', description: 'Left padding override' },
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' },
635
1274
  fillColor: { type: 'string', description: 'Group background color (hex)' },
636
1275
  fillOpacity: { type: 'number', description: 'Group background opacity (0-1)' },
637
1276
  cornerRadius: { type: 'number', description: 'Corner radius' },
638
- x: { type: 'number', description: 'Group X position (-1 to 1, 0 is center)', default: 0 },
639
- y: { type: 'number', description: 'Group Y position (-1 to 1, POSITIVE is UP/TOP). 0.15=upper, 0=center, -0.3=lower', default: 0 },
640
- width: { type: 'number', description: 'Group width (0-1 relative to canvas)', default: 0.6 },
641
- height: { type: 'number', description: 'Group height (0-1 relative to canvas)', default: 0.4 },
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 },
642
1281
  rotation: { type: 'number', description: 'Rotation in degrees', default: 0 },
643
1282
  opacity: { type: 'number', description: 'Opacity 0-1', default: 1 },
644
1283
  children: {
@@ -647,7 +1286,7 @@ exports.stateTools = [
647
1286
  items: {
648
1287
  type: 'object',
649
1288
  properties: {
650
- type: { type: 'string', enum: ['text', 'image', 'video'], description: 'Child layer type' },
1289
+ type: { type: 'string', enum: ['text', 'image', 'video', 'rectangle', 'ellipse', 'polygon', 'star', 'line'], description: 'Child layer type' },
651
1290
  name: { type: 'string', description: 'Child layer name' },
652
1291
  content: { type: 'string', description: 'Text content' },
653
1292
  fontFamily: { type: 'string', description: 'Font family' },
@@ -655,19 +1294,70 @@ exports.stateTools = [
655
1294
  fontWeight: { type: 'string', description: 'Font weight' },
656
1295
  color: { type: 'string', description: 'Text color in hex' },
657
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' },
658
1298
  letterSpacing: { type: 'number', description: 'Letter spacing' },
659
1299
  lineHeight: { type: 'number', description: 'Line height multiplier' },
660
- textType: { type: 'string', enum: ['point', 'frame'], description: 'Text authoring mode' },
661
- textTransformMode: { type: 'string', enum: ['scale', 'box'], description: 'Text sizing mode' },
1300
+ textType: { type: 'string', enum: ['frame'], description: 'Text mode (always "frame")' },
1301
+ textTransformMode: { type: 'string', enum: ['box'], description: 'Text sizing mode (always "box")' },
662
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.' },
663
1305
  frameWidthPx: { type: 'number', description: 'Frame width in CSS pixels' },
664
1306
  frameHeightPx: { type: 'number', description: 'Frame height in CSS pixels' },
665
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' },
666
1312
  mediaUrl: { type: 'string', description: 'Media URL (for image/video)' },
667
1313
  objectFit: { type: 'string', enum: ['cover', 'contain'], description: 'Object fit mode' },
668
- width: { type: 'number', description: 'Child width (0-1 relative to group)' },
669
- height: { type: 'number', description: 'Child height (0-1 relative to group)' },
1314
+ width: { type: 'number', description: 'Width as % of canvas (e.g. 60 = 60%)' },
1315
+ height: { type: 'number', description: 'Height as % of canvas' },
670
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)' },
671
1361
  },
672
1362
  required: ['type'],
673
1363
  },
@@ -678,36 +1368,63 @@ exports.stateTools = [
678
1368
  },
679
1369
  {
680
1370
  name: 'modify_layer',
681
- description: 'Modify an existing layer by ID. Supports text, image, video, and group properties.',
1371
+ description: 'Modify an existing layer by ID. Supports text, image, video, group, and shape properties.',
682
1372
  inputSchema: {
683
1373
  type: 'object',
684
1374
  properties: {
685
1375
  layerId: { type: 'string', description: 'ID of the layer to modify' },
686
- // Transform properties (positive Y is UP/TOP, negative Y is down/bottom)
687
- x: { type: 'number', description: 'X position (-1 to 1, 0 is center)' },
688
- y: { type: 'number', description: 'Y position (-1 to 1, POSITIVE is UP/TOP, negative is down/bottom)' },
689
- width: { type: 'number', description: 'Width' },
690
- 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' },
691
1381
  rotation: { type: 'number', description: 'Rotation in degrees' },
692
1382
  opacity: { type: 'number', description: 'Opacity 0-1' },
693
1383
  visible: { type: 'boolean', description: 'Layer visibility' },
694
1384
  locked: { type: 'boolean', description: 'Layer lock state' },
695
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)' },
696
1405
  // Text layer properties
697
1406
  content: { type: 'string' },
698
- textType: { type: 'string', enum: ['point', 'frame'], description: 'Text authoring mode' },
1407
+ textType: { type: 'string', enum: ['frame'], description: 'Text mode (always "frame")' },
699
1408
  fontFamily: { type: 'string' },
700
1409
  fontSize: { type: 'number' },
701
1410
  fontWeight: { type: 'string' },
702
1411
  color: { type: 'string' },
703
1412
  textAlign: { type: 'string' },
1413
+ verticalAlign: { type: 'string', enum: ['top', 'middle', 'bottom'], description: 'Vertical text alignment inside the frame' },
704
1414
  letterSpacing: { type: 'number' },
705
1415
  lineHeight: { type: 'number' },
706
- textTransformMode: { type: 'string', enum: ['scale', 'box'] },
1416
+ textTransformMode: { type: 'string', enum: ['box'] },
707
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.' },
708
1420
  frameWidthPx: { type: 'number' },
709
1421
  frameHeightPx: { type: 'number' },
710
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' },
711
1428
  // Text animation properties (all type-specific params supported - see add_layer for descriptions)
712
1429
  animationType: {
713
1430
  type: 'string',
@@ -768,18 +1485,45 @@ exports.stateTools = [
768
1485
  layoutMode: { type: 'string', enum: ['row', 'column'], description: 'Group layout direction' },
769
1486
  widthMode: { type: 'string', enum: ['hug', 'fixed'], description: 'Group horizontal sizing mode' },
770
1487
  heightMode: { type: 'string', enum: ['hug', 'fixed'], description: 'Group vertical sizing mode' },
771
- gap: { type: 'number', description: 'Gap between children (normalized 0-1)' },
1488
+ gap: { type: 'number', description: 'Space between children as % of canvas (2=small, 4=medium, 6=large)' },
772
1489
  justifyContent: { type: 'string', enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], description: 'Group main-axis alignment' },
773
1490
  alignItems: { type: 'string', enum: ['start', 'center', 'end'], description: 'Group cross-axis alignment' },
774
1491
  wrap: { type: 'boolean', description: 'Group wrap children' },
775
- padding: { type: 'number', description: 'Group uniform padding' },
776
- paddingTop: { type: 'number', description: 'Group top padding' },
777
- paddingRight: { type: 'number', description: 'Group right padding' },
778
- paddingBottom: { type: 'number', description: 'Group bottom padding' },
779
- paddingLeft: { type: 'number', description: 'Group left padding' },
780
- fillColor: { type: 'string', description: 'Group background color' },
781
- fillOpacity: { type: 'number', description: 'Group background opacity' },
782
- cornerRadius: { type: 'number', description: 'Group corner radius' },
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' },
783
1527
  },
784
1528
  required: ['layerId'],
785
1529
  },
@@ -805,12 +1549,15 @@ IMPORTANT - Two effect pipelines:
805
1549
  'dither-floyd-steinberg', 'dither-atkinson', 'dither-jarvis-judice-ninke',
806
1550
  'dither-stucki', 'dither-burkes', 'dither-sierra', 'dither-two-row-sierra',
807
1551
  'dither-sierra-lite',
1552
+ 'color-separation',
808
1553
  // Halftone (WebGPU - supports post-processes)
809
1554
  'halftone-mono', 'halftone-cmyk',
810
1555
  // Glitch (WebGPU - supports post-processes)
811
1556
  'glitch-chromatic', 'glitch-digital', 'glitch-vhs', 'glitch-weird',
812
1557
  // Art (WebGPU - supports post-processes)
813
1558
  'art-kuwahara', 'art-crosshatch', 'art-lineart', 'art-engraving', 'art-stipple',
1559
+ // Special (WebGPU - supports post-processes)
1560
+ 'special-warp',
814
1561
  ],
815
1562
  description: 'Effect ID to apply',
816
1563
  },
@@ -856,7 +1603,7 @@ Common combinations:
856
1603
  'scanlines', 'vignette', 'chromatic-aberration', 'curvature',
857
1604
  'grain', 'noise', 'pixelate', 'wave', 'rgb-glitch', 'brightness-contrast',
858
1605
  'color-tint', 'palette', 'jitter', 'bloom', 'dot-screen', 'sepia',
859
- 'grid', 'light-beams', 'motion-blur',
1606
+ 'grid', 'light-beams', 'motion-blur', 'blob-tracking',
860
1607
  ],
861
1608
  description: 'Post-process type',
862
1609
  },
@@ -914,6 +1661,17 @@ Common combinations:
914
1661
  density: { type: 'number', description: 'Light-beams: ray spread (0.5-2)', default: 1 },
915
1662
  animated: { type: 'boolean', description: 'Light-beams/Grid: enable animation', default: false },
916
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 },
917
1675
  },
918
1676
  required: ['type'],
919
1677
  },
@@ -929,6 +1687,22 @@ Common combinations:
929
1687
  required: ['layerId'],
930
1688
  },
931
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
+ },
932
1706
  {
933
1707
  name: 'get_state',
934
1708
  description: 'Get the current poster state as LayerShareState JSON (for debugging or verification)',
@@ -978,11 +1752,24 @@ async function handleStateTool(name, args) {
978
1752
  case 'create_poster': {
979
1753
  const aspectRatio = params.aspectRatio || '9:16';
980
1754
  const backgroundColor = params.backgroundColor || '#1a1a1a';
1755
+ const canvasColor = params.canvasColor;
1756
+ const overflow = params.overflow;
981
1757
  currentPoster = {
982
- 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
+ },
983
1769
  layers: [createDefaultBackgroundLayer(backgroundColor)],
984
1770
  postProcesses: [],
985
1771
  };
1772
+ persistState();
986
1773
  return {
987
1774
  content: [
988
1775
  {
@@ -997,12 +1784,110 @@ async function handleStateTool(name, args) {
997
1784
  ],
998
1785
  };
999
1786
  }
1000
- case 'set_background': {
1001
- if (!currentPoster) {
1002
- return {
1003
- 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,
1004
1843
  };
1844
+ currentPoster.canvas.padding = nextPadding;
1005
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();
1006
1891
  // Background is always at index 0
1007
1892
  const bgLayer = currentPoster.layers[0];
1008
1893
  if (bgLayer.type !== 'background') {
@@ -1014,53 +1899,8 @@ async function handleStateTool(name, args) {
1014
1899
  const contentType = requestedType === 'gradient' ? 'solid' : requestedType;
1015
1900
  bgLayer.contentType = contentType;
1016
1901
  if (requestedType === 'gradient') {
1017
- const gradientStyle = params.gradientStyle === 'radial' ? 'radial' : 'linear';
1018
- const explicitStops = parseGradientStopsInput(params.gradientStops, gradientStyle === 'radial' ? DEFAULT_RADIAL_GRADIENT_FILL.stops : DEFAULT_LINEAR_GRADIENT_FILL.stops);
1019
- const gradient = gradientStyle === 'radial'
1020
- ? normalizeRadialGradientFill({
1021
- type: 'radial',
1022
- center: {
1023
- x: Number.isFinite(params.gradientCenterX)
1024
- ? params.gradientCenterX
1025
- : DEFAULT_RADIAL_GRADIENT_FILL.center.x,
1026
- y: Number.isFinite(params.gradientCenterY)
1027
- ? params.gradientCenterY
1028
- : DEFAULT_RADIAL_GRADIENT_FILL.center.y,
1029
- },
1030
- radius: Number.isFinite(params.gradientRadius)
1031
- ? params.gradientRadius
1032
- : DEFAULT_RADIAL_GRADIENT_FILL.radius,
1033
- stops: explicitStops ?? [
1034
- {
1035
- color: params.gradientStartColor || DEFAULT_RADIAL_GRADIENT_FILL.stops[0].color,
1036
- opacity: DEFAULT_RADIAL_GRADIENT_FILL.stops[0].opacity,
1037
- position: DEFAULT_RADIAL_GRADIENT_FILL.stops[0].position,
1038
- },
1039
- {
1040
- color: params.gradientEndColor || DEFAULT_RADIAL_GRADIENT_FILL.stops[1].color,
1041
- opacity: DEFAULT_RADIAL_GRADIENT_FILL.stops[1].opacity,
1042
- position: DEFAULT_RADIAL_GRADIENT_FILL.stops[1].position,
1043
- },
1044
- ],
1045
- })
1046
- : normalizeLinearGradientFill({
1047
- type: 'linear',
1048
- angle: Number.isFinite(params.gradientAngle)
1049
- ? params.gradientAngle
1050
- : DEFAULT_LINEAR_GRADIENT_FILL.angle,
1051
- stops: explicitStops ?? [
1052
- {
1053
- color: params.gradientStartColor || DEFAULT_LINEAR_GRADIENT_FILL.stops[0].color,
1054
- opacity: DEFAULT_LINEAR_GRADIENT_FILL.stops[0].opacity,
1055
- position: DEFAULT_LINEAR_GRADIENT_FILL.stops[0].position,
1056
- },
1057
- {
1058
- color: params.gradientEndColor || DEFAULT_LINEAR_GRADIENT_FILL.stops[1].color,
1059
- opacity: DEFAULT_LINEAR_GRADIENT_FILL.stops[1].opacity,
1060
- position: DEFAULT_LINEAR_GRADIENT_FILL.stops[1].position,
1061
- },
1062
- ],
1063
- });
1902
+ const gradient = buildFillFromParams(params)
1903
+ ?? normalizeLinearGradientFill(DEFAULT_LINEAR_GRADIENT_FILL);
1064
1904
  bgLayer.fill = gradient;
1065
1905
  bgLayer.solidColor = gradient.stops[0].color;
1066
1906
  currentPoster.canvas.backgroundColor = gradient.stops[0].color;
@@ -1105,6 +1945,7 @@ async function handleStateTool(name, args) {
1105
1945
  objectFit: params.objectFit || 'cover',
1106
1946
  };
1107
1947
  }
1948
+ persistState();
1108
1949
  return {
1109
1950
  content: [
1110
1951
  {
@@ -1119,11 +1960,7 @@ async function handleStateTool(name, args) {
1119
1960
  };
1120
1961
  }
1121
1962
  case 'add_layer': {
1122
- if (!currentPoster) {
1123
- return {
1124
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1125
- };
1126
- }
1963
+ ensurePoster();
1127
1964
  const layerType = params.type;
1128
1965
  if (!layerType) {
1129
1966
  return {
@@ -1135,49 +1972,76 @@ async function handleStateTool(name, args) {
1135
1972
  content: [{ type: 'text', text: 'Error: Use set_background to modify the background layer' }],
1136
1973
  };
1137
1974
  }
1975
+ const isShapeType = ['rectangle', 'ellipse', 'polygon', 'star', 'line'].includes(layerType);
1138
1976
  const layerId = generateId();
1977
+ const visualOverrides = parseVisualOverrides(params);
1978
+ const originOverrides = parseTransformOrigin(params);
1139
1979
  const baseLayer = {
1140
1980
  id: layerId,
1141
1981
  type: layerType,
1142
1982
  name: params.name || `${layerType.charAt(0).toUpperCase() + layerType.slice(1)} Layer`,
1143
1983
  visible: true,
1144
1984
  locked: false,
1985
+ ...parseFlexChildProps(params),
1986
+ ...visualOverrides,
1145
1987
  transform: {
1146
- x: params.x ?? 0,
1147
- y: params.y ?? 0,
1148
- width: params.width ?? 1,
1149
- 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),
1150
1993
  rotation: params.rotation ?? 0,
1151
- opacity: params.opacity ?? 1,
1994
+ opacity: normalizeOpacity(params.opacity, 1),
1995
+ ...originOverrides,
1152
1996
  },
1153
1997
  };
1154
1998
  let layer;
1155
1999
  if (layerType === 'text') {
1156
2000
  const animation = buildAnimationSettings(params);
1157
- // textType is canonical, textTransformMode is legacy. textType wins if both provided.
1158
- const requestedTextType = params.textType;
1159
- const requestedTransformMode = params.textTransformMode;
1160
- const textType = requestedTextType ?? (requestedTransformMode === 'scale' ? 'point' : 'frame');
1161
- const textTransformMode = textType === 'point' ? 'scale' : 'box';
1162
- // 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
+ }
1163
2024
  layer = {
1164
2025
  ...baseLayer,
1165
2026
  type: 'text',
1166
- content: params.content || 'Text',
1167
- textType,
2027
+ content,
2028
+ textType: 'frame',
1168
2029
  fontFamily: normalizeFontFamily(params.fontFamily),
1169
- fontSize: params.fontSize || 72,
2030
+ fontSize,
1170
2031
  fontWeight: normalizeFontWeight(params.fontWeight),
1171
2032
  color: params.color || '#ffffff',
1172
2033
  textAlign: params.textAlign || 'center',
2034
+ ...(params.verticalAlign ? { verticalAlign: params.verticalAlign } : {}),
1173
2035
  letterSpacing: params.letterSpacing ?? 0,
1174
2036
  lineHeight: params.lineHeight ?? 1.2,
1175
- textTransformMode,
1176
- ...(params.textBoxWidth ? { textBoxWidth: params.textBoxWidth } : {}),
1177
- ...(params.frameWidthPx ? { frameWidthPx: params.frameWidthPx } : {}),
1178
- ...(params.frameHeightPx ? { frameHeightPx: params.frameHeightPx } : {}),
1179
- ...(params.autoSize ? { autoSize: params.autoSize } : {}),
2037
+ textTransformMode: 'box',
2038
+ ...(typeof textBoxWidth === 'number' ? { textBoxWidth } : {}),
2039
+ ...(typeof frameWidthPx === 'number' ? { frameWidthPx } : {}),
2040
+ ...(typeof frameHeightPx === 'number' ? { frameHeightPx } : {}),
2041
+ ...(typeof autoSize === 'string' ? { autoSize } : {}),
1180
2042
  ...(animation ? { animation } : {}),
2043
+ ...(parseTextTransform(params) ? { textTransform: parseTextTransform(params) } : {}),
2044
+ ...parseTextDecoration(params),
1181
2045
  };
1182
2046
  }
1183
2047
  else if (layerType === 'image') {
@@ -1214,10 +2078,140 @@ async function handleStateTool(name, args) {
1214
2078
  playbackSpeed: 1,
1215
2079
  };
1216
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
+ }
1217
2210
  else {
1218
2211
  layer = baseLayer;
1219
2212
  }
1220
2213
  currentPoster.layers.push(layer);
2214
+ persistState();
1221
2215
  return {
1222
2216
  content: [
1223
2217
  {
@@ -1233,11 +2227,7 @@ async function handleStateTool(name, args) {
1233
2227
  };
1234
2228
  }
1235
2229
  case 'add_group': {
1236
- if (!currentPoster) {
1237
- return {
1238
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1239
- };
1240
- }
2230
+ ensurePoster();
1241
2231
  const children = params.children;
1242
2232
  if (!children || !Array.isArray(children) || children.length === 0) {
1243
2233
  return {
@@ -1246,46 +2236,71 @@ async function handleStateTool(name, args) {
1246
2236
  }
1247
2237
  const groupId = generateId();
1248
2238
  const childLayers = [];
2239
+ const artboardAspect = getArtboardAspect(currentPoster.canvas.aspectRatio);
1249
2240
  for (const childParams of children) {
1250
2241
  const childType = childParams.type || 'text';
2242
+ const isChildShapeType = ['rectangle', 'ellipse', 'polygon', 'star', 'line'].includes(childType);
1251
2243
  const childId = generateId();
2244
+ const childVisualOverrides = parseVisualOverrides(childParams);
2245
+ const childOriginOverrides = parseTransformOrigin(childParams);
1252
2246
  const childBase = {
1253
2247
  id: childId,
1254
2248
  type: childType,
1255
2249
  name: childParams.name || `${childType.charAt(0).toUpperCase() + childType.slice(1)} Layer`,
1256
2250
  visible: true,
1257
2251
  locked: false,
2252
+ ...parseFlexChildProps(childParams),
2253
+ ...childVisualOverrides,
1258
2254
  transform: {
1259
2255
  x: 0,
1260
2256
  y: 0,
1261
- width: childParams.width ?? 1,
1262
- height: childParams.height ?? 1,
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),
1263
2260
  rotation: 0,
1264
- opacity: childParams.opacity ?? 1,
2261
+ opacity: normalizeOpacity(childParams.opacity, 1),
2262
+ ...childOriginOverrides,
1265
2263
  },
1266
2264
  };
1267
2265
  if (childType === 'text') {
1268
- const requestedTextType = childParams.textType;
1269
- const requestedTransformMode = childParams.textTransformMode;
1270
- const textType = requestedTextType ?? (requestedTransformMode === 'scale' ? 'point' : 'frame');
1271
- const textTransformMode = textType === 'point' ? 'scale' : 'box';
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
+ }
1272
2284
  childLayers.push({
1273
2285
  ...childBase,
1274
2286
  type: 'text',
1275
2287
  content: childParams.content || 'Text',
1276
- textType,
2288
+ textType: 'frame',
1277
2289
  fontFamily: normalizeFontFamily(childParams.fontFamily),
1278
- fontSize: childParams.fontSize || 72,
2290
+ fontSize: childFontSize,
1279
2291
  fontWeight: normalizeFontWeight(childParams.fontWeight),
1280
2292
  color: childParams.color || '#ffffff',
1281
2293
  textAlign: childParams.textAlign || 'center',
2294
+ ...(childParams.verticalAlign ? { verticalAlign: childParams.verticalAlign } : {}),
1282
2295
  letterSpacing: childParams.letterSpacing ?? 0,
1283
2296
  lineHeight: childParams.lineHeight ?? 1.2,
1284
- textTransformMode,
1285
- ...(childParams.textBoxWidth ? { textBoxWidth: childParams.textBoxWidth } : {}),
1286
- ...(childParams.frameWidthPx ? { frameWidthPx: childParams.frameWidthPx } : {}),
1287
- ...(childParams.frameHeightPx ? { frameHeightPx: childParams.frameHeightPx } : {}),
1288
- ...(childParams.autoSize ? { autoSize: childParams.autoSize } : {}),
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),
1289
2304
  });
1290
2305
  }
1291
2306
  else if (childType === 'image') {
@@ -1316,25 +2331,155 @@ async function handleStateTool(name, args) {
1316
2331
  playbackSpeed: 1,
1317
2332
  });
1318
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
+ }
1319
2463
  }
1320
2464
  if (childLayers.length === 0) {
1321
2465
  return {
1322
2466
  content: [{ type: 'text', text: 'Error: No valid child layers could be created' }],
1323
2467
  };
1324
2468
  }
1325
- // Build padding
1326
- const uniformPadding = params.padding ?? 0;
2469
+ // Build padding — convert from CSS % to internal normalized values
2470
+ const uniformPaddingPct = params.padding ?? 0;
2471
+ const uniformPadding = cssPercentToSize(uniformPaddingPct, 0);
1327
2472
  const groupPadding = {
1328
- top: params.paddingTop ?? uniformPadding,
1329
- right: params.paddingRight ?? uniformPadding,
1330
- bottom: params.paddingBottom ?? uniformPadding,
1331
- left: params.paddingLeft ?? uniformPadding,
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),
1332
2477
  };
1333
2478
  const layoutMode = params.layoutMode || 'column';
1334
2479
  const widthMode = params.widthMode ?? (params.width !== undefined ? 'fixed' : 'hug');
1335
2480
  const heightMode = params.heightMode ?? (params.height !== undefined ? 'fixed' : 'hug');
1336
- const baseWidth = params.width ?? 0.6;
1337
- const baseHeight = params.height ?? 0.4;
2481
+ const baseWidth = cssPercentToSize(params.width, 60);
2482
+ const baseHeight = cssPercentToSize(params.height, 40);
1338
2483
  const groupLayer = {
1339
2484
  id: groupId,
1340
2485
  type: 'group',
@@ -1342,12 +2487,12 @@ async function handleStateTool(name, args) {
1342
2487
  visible: true,
1343
2488
  locked: false,
1344
2489
  transform: {
1345
- x: params.x ?? 0,
1346
- y: params.y ?? 0,
2490
+ x: cssPercentToX(params.x, 50),
2491
+ y: cssPercentToY(params.y, 50),
1347
2492
  width: 1,
1348
2493
  height: 1,
1349
2494
  rotation: params.rotation ?? 0,
1350
- opacity: params.opacity ?? 1,
2495
+ opacity: normalizeOpacity(params.opacity, 1),
1351
2496
  },
1352
2497
  children: childLayers,
1353
2498
  expanded: true,
@@ -1356,7 +2501,7 @@ async function handleStateTool(name, args) {
1356
2501
  layoutMode,
1357
2502
  widthMode,
1358
2503
  heightMode,
1359
- gap: params.gap ?? 0.02,
2504
+ gap: cssPercentToSize(params.gap, 2),
1360
2505
  justifyContent: params.justifyContent ?? 'start',
1361
2506
  alignItems: params.alignItems ?? 'center',
1362
2507
  wrap: params.wrap ?? false,
@@ -1366,6 +2511,7 @@ async function handleStateTool(name, args) {
1366
2511
  ...(params.cornerRadius !== undefined ? { cornerRadius: params.cornerRadius } : {}),
1367
2512
  };
1368
2513
  currentPoster.layers.push(groupLayer);
2514
+ persistState();
1369
2515
  return {
1370
2516
  content: [
1371
2517
  {
@@ -1381,11 +2527,7 @@ async function handleStateTool(name, args) {
1381
2527
  };
1382
2528
  }
1383
2529
  case 'modify_layer': {
1384
- if (!currentPoster) {
1385
- return {
1386
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1387
- };
1388
- }
2530
+ ensurePoster();
1389
2531
  const layerId = params.layerId;
1390
2532
  if (!layerId) {
1391
2533
  return {
@@ -1399,11 +2541,28 @@ async function handleStateTool(name, args) {
1399
2541
  };
1400
2542
  }
1401
2543
  const layer = currentPoster.layers[layerIndex];
2544
+ const isShapeType = ['rectangle', 'ellipse', 'polygon', 'star', 'line'].includes(layer.type);
1402
2545
  // Update transform properties
1403
- const transformKeys = ['x', 'y', 'width', 'height', 'rotation', 'opacity'];
2546
+ const transformKeys = isShapeType
2547
+ ? ['x', 'y', 'rotation', 'opacity']
2548
+ : ['x', 'y', 'width', 'height', 'rotation', 'opacity'];
1404
2549
  for (const key of transformKeys) {
1405
2550
  if (params[key] !== undefined) {
1406
- 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
+ }
1407
2566
  }
1408
2567
  }
1409
2568
  // Update base properties
@@ -1413,9 +2572,46 @@ async function handleStateTool(name, args) {
1413
2572
  layer.locked = params.locked;
1414
2573
  if (params.name !== undefined)
1415
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;
1416
2611
  // Update text layer properties
1417
2612
  if (layer.type === 'text') {
1418
2613
  const textLayer = layer;
2614
+ const artboardAspect = getArtboardAspect(currentPoster.canvas.aspectRatio);
1419
2615
  if (params.content !== undefined)
1420
2616
  textLayer.content = params.content;
1421
2617
  if (params.fontFamily !== undefined)
@@ -1428,18 +2624,15 @@ async function handleStateTool(name, args) {
1428
2624
  textLayer.color = params.color;
1429
2625
  if (params.textAlign !== undefined)
1430
2626
  textLayer.textAlign = params.textAlign;
2627
+ if (params.verticalAlign !== undefined)
2628
+ textLayer.verticalAlign = params.verticalAlign;
1431
2629
  if (params.letterSpacing !== undefined)
1432
2630
  textLayer.letterSpacing = params.letterSpacing;
1433
2631
  if (params.lineHeight !== undefined)
1434
2632
  textLayer.lineHeight = params.lineHeight;
1435
- // Reconcile textType (canonical) vs textTransformMode (legacy). textType wins.
1436
- if (params.textType !== undefined || params.textTransformMode !== undefined) {
1437
- const resolvedTextType = params.textType !== undefined
1438
- ? params.textType
1439
- : (params.textTransformMode === 'scale' ? 'point' : 'frame');
1440
- textLayer.textType = resolvedTextType;
1441
- textLayer.textTransformMode = resolvedTextType === 'point' ? 'scale' : 'box';
1442
- }
2633
+ // All text is frame text silently ignore any 'point'/'scale' requests
2634
+ textLayer.textType = 'frame';
2635
+ textLayer.textTransformMode = 'box';
1443
2636
  if (params.textBoxWidth !== undefined)
1444
2637
  textLayer.textBoxWidth = params.textBoxWidth;
1445
2638
  if (params.frameWidthPx !== undefined)
@@ -1448,6 +2641,29 @@ async function handleStateTool(name, args) {
1448
2641
  textLayer.frameHeightPx = params.frameHeightPx;
1449
2642
  if (params.autoSize !== undefined)
1450
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
+ }
1451
2667
  // Update animation settings
1452
2668
  if (params.animationType !== undefined) {
1453
2669
  if (params.animationType === 'none') {
@@ -1591,7 +2807,7 @@ async function handleStateTool(name, args) {
1591
2807
  if (params.heightMode !== undefined)
1592
2808
  groupLayer.heightMode = params.heightMode;
1593
2809
  if (params.gap !== undefined)
1594
- groupLayer.gap = params.gap;
2810
+ groupLayer.gap = cssPercentToSize(params.gap, (groupLayer.gap ?? 0.02) * 100);
1595
2811
  if (params.justifyContent !== undefined)
1596
2812
  groupLayer.justifyContent = params.justifyContent;
1597
2813
  if (params.alignItems !== undefined)
@@ -1604,31 +2820,154 @@ async function handleStateTool(name, args) {
1604
2820
  groupLayer.fillOpacity = params.fillOpacity;
1605
2821
  if (params.cornerRadius !== undefined)
1606
2822
  groupLayer.cornerRadius = params.cornerRadius;
1607
- // Padding: uniform or individual overrides
2823
+ const groupCornerRadii = parseCornerRadii(params);
2824
+ if (groupCornerRadii)
2825
+ groupLayer.cornerRadii = groupCornerRadii;
2826
+ // Padding: uniform or individual overrides (CSS % to normalized)
1608
2827
  if (params.padding !== undefined || params.paddingTop !== undefined || params.paddingRight !== undefined || params.paddingBottom !== undefined || params.paddingLeft !== undefined) {
1609
2828
  const existing = groupLayer.padding || { top: 0, right: 0, bottom: 0, left: 0 };
1610
- const uniform = params.padding ?? undefined;
2829
+ const uniformPct = params.padding !== undefined ? parseNumericInput(params.padding) : undefined;
2830
+ const uniform = uniformPct !== undefined ? cssPercentToSize(uniformPct, 0) : undefined;
1611
2831
  groupLayer.padding = {
1612
- top: params.paddingTop ?? uniform ?? existing.top,
1613
- right: params.paddingRight ?? uniform ?? existing.right,
1614
- bottom: params.paddingBottom ?? uniform ?? existing.bottom,
1615
- left: params.paddingLeft ?? uniform ?? existing.left,
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,
1616
2836
  };
1617
2837
  }
1618
2838
  // Allow updating baseWidth/baseHeight via width/height params
1619
2839
  if (params.width !== undefined) {
1620
- groupLayer.baseWidth = params.width;
2840
+ groupLayer.baseWidth = cssPercentToSize(params.width, groupLayer.baseWidth * 100);
1621
2841
  if (params.widthMode === undefined) {
1622
2842
  groupLayer.widthMode = 'fixed';
1623
2843
  }
1624
2844
  }
1625
2845
  if (params.height !== undefined) {
1626
- groupLayer.baseHeight = params.height;
2846
+ groupLayer.baseHeight = cssPercentToSize(params.height, groupLayer.baseHeight * 100);
1627
2847
  if (params.heightMode === undefined) {
1628
2848
  groupLayer.heightMode = 'fixed';
1629
2849
  }
1630
2850
  }
1631
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();
1632
2971
  return {
1633
2972
  content: [
1634
2973
  {
@@ -1643,11 +2982,7 @@ async function handleStateTool(name, args) {
1643
2982
  };
1644
2983
  }
1645
2984
  case 'apply_effect': {
1646
- if (!currentPoster) {
1647
- return {
1648
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1649
- };
1650
- }
2985
+ ensurePoster();
1651
2986
  const effectId = params.effectId;
1652
2987
  if (!effectId) {
1653
2988
  return {
@@ -1656,6 +2991,7 @@ async function handleStateTool(name, args) {
1656
2991
  }
1657
2992
  if (effectId === 'none') {
1658
2993
  delete currentPoster.effect;
2994
+ persistState();
1659
2995
  return {
1660
2996
  content: [
1661
2997
  {
@@ -1722,12 +3058,15 @@ async function handleStateTool(name, args) {
1722
3058
  };
1723
3059
  // Dither has BUILT-IN palette and bloom (WebGL pipeline)
1724
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);
1725
3065
  effect.dither = {
1726
3066
  pattern: patternMap[effectId] || 'floydSteinberg',
1727
3067
  pixelation: clamp(params.pixelation, 1, 20, 3),
1728
- // Palette: use paletteId for built-in palette, or custom colors array
1729
- paletteId: params.paletteId ?? null,
1730
- colors: params.colors ?? ['#000000', '#ffffff'],
3068
+ paletteId: ditherPaletteId,
3069
+ colors: ditherColors,
1731
3070
  brightness: clamp(params.brightness, 0, 2, 1),
1732
3071
  contrast: clamp(params.contrast, 0.5, 2, 1.2),
1733
3072
  threshold: 1.0,
@@ -1900,6 +3239,7 @@ async function handleStateTool(name, args) {
1900
3239
  };
1901
3240
  }
1902
3241
  currentPoster.effect = effect;
3242
+ persistState();
1903
3243
  return {
1904
3244
  content: [
1905
3245
  {
@@ -1914,11 +3254,7 @@ async function handleStateTool(name, args) {
1914
3254
  };
1915
3255
  }
1916
3256
  case 'add_postprocess': {
1917
- if (!currentPoster) {
1918
- return {
1919
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1920
- };
1921
- }
3257
+ ensurePoster();
1922
3258
  const ppType = params.type;
1923
3259
  if (!ppType) {
1924
3260
  return {
@@ -1964,6 +3300,18 @@ async function handleStateTool(name, args) {
1964
3300
  'decay', // light-beams
1965
3301
  'density', // light-beams
1966
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
1967
3315
  ];
1968
3316
  const allSettingKeys = [...commonKeys, ...additionalKeys];
1969
3317
  for (const key of allSettingKeys) {
@@ -1976,6 +3324,11 @@ async function handleStateTool(name, args) {
1976
3324
  settings.type = settings.blurType;
1977
3325
  delete settings.blurType;
1978
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
+ }
1979
3332
  const postProcess = {
1980
3333
  id: ppId,
1981
3334
  type: ppType,
@@ -1983,6 +3336,7 @@ async function handleStateTool(name, args) {
1983
3336
  settings,
1984
3337
  };
1985
3338
  currentPoster.postProcesses.push(postProcess);
3339
+ persistState();
1986
3340
  // Build response with warning if dither is active
1987
3341
  const response = {
1988
3342
  success: true,
@@ -2005,11 +3359,7 @@ async function handleStateTool(name, args) {
2005
3359
  };
2006
3360
  }
2007
3361
  case 'remove_layer': {
2008
- if (!currentPoster) {
2009
- return {
2010
- content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
2011
- };
2012
- }
3362
+ ensurePoster();
2013
3363
  const layerId = params.layerId;
2014
3364
  if (!layerId) {
2015
3365
  return {
@@ -2028,6 +3378,7 @@ async function handleStateTool(name, args) {
2028
3378
  };
2029
3379
  }
2030
3380
  currentPoster.layers.splice(layerIndex, 1);
3381
+ persistState();
2031
3382
  return {
2032
3383
  content: [
2033
3384
  {
@@ -2041,12 +3392,67 @@ async function handleStateTool(name, args) {
2041
3392
  ],
2042
3393
  };
2043
3394
  }
2044
- case 'get_state': {
2045
- 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) {
3401
+ return {
3402
+ content: [{ type: 'text', text: 'Error: layerId is required' }],
3403
+ };
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) {
2046
3412
  return {
2047
- content: [{ type: 'text', text: 'No poster created yet. Use create_poster first.' }],
3413
+ content: [{ type: 'text', text: 'Error: Cannot move background layer' }],
2048
3414
  };
2049
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();
2050
3456
  return {
2051
3457
  content: [
2052
3458
  {