@efectoapp/mcp-server 0.1.23 → 0.1.26

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,13 +14,9 @@
14
14
  */
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.stateTools = void 0;
17
- exports.ensurePoster = ensurePoster;
18
17
  exports.resetState = resetState;
19
18
  exports.getCurrentState = getCurrentState;
20
19
  exports.handleStateTool = handleStateTool;
21
- const os_1 = require("os");
22
- const fs_1 = require("fs");
23
- const path_1 = require("path");
24
20
  // Helper to clamp values to valid ranges
25
21
  function clamp(value, min, max, fallback) {
26
22
  const v = value ?? fallback;
@@ -124,6 +120,24 @@ function parseNormalizedFrameDimension(input) {
124
120
  return undefined;
125
121
  return clamp(value, 0.01, 2, value);
126
122
  }
123
+ /**
124
+ * Auto-fit font size so the longest word fits on one line within frameWidthPx.
125
+ * Uses a conservative 0.72 average-char-width factor (bold uppercase in DM Sans
126
+ * averages ~0.68–0.72 of fontSize). The +0.5 accounts for text padding
127
+ * (TEXT_PADDING_FACTOR * 2 = 0.25 * 2).
128
+ */
129
+ function autoFitFontSize(content, fontSize, letterSpacing, frameWidthPx) {
130
+ const words = content.split(/\s+/);
131
+ const longestWord = words.reduce((a, b) => Array.from(a).length >= Array.from(b).length ? a : b, '');
132
+ const charCount = Array.from(longestWord).length;
133
+ if (charCount === 0)
134
+ return fontSize;
135
+ const spacingPx = Math.max(0, charCount - 1) * Math.abs(letterSpacing);
136
+ const maxFontSize = (frameWidthPx - spacingPx) / (0.72 * charCount + 0.5);
137
+ if (fontSize <= maxFontSize)
138
+ return fontSize;
139
+ return Math.max(12, Math.floor(maxFontSize));
140
+ }
127
141
  function textNormalizedToPx(normalized, artboardAspect, axis) {
128
142
  if (axis === 'height') {
129
143
  return normalized / TEXT_WORLD_SCALE_FACTOR;
@@ -399,75 +413,9 @@ function normalizeFontFamily(input) {
399
413
  }
400
414
  // Current poster state (single poster for now)
401
415
  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
- }
464
416
  // Reset state (useful for testing)
465
417
  function resetState() {
466
418
  currentPoster = null;
467
- try {
468
- (0, fs_1.unlinkSync)(STATE_FILE_PATH);
469
- }
470
- catch { /* ignore */ }
471
419
  }
472
420
  // Get current state
473
421
  function getCurrentState() {
@@ -800,113 +748,6 @@ function parseGradientStopsInput(input, fallbackStops) {
800
748
  .filter((stop) => stop !== null);
801
749
  return parsed.length >= MIN_GRADIENT_STOPS ? parsed : null;
802
750
  }
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
- };
910
751
  // Tool definitions
911
752
  exports.stateTools = [
912
753
  {
@@ -1115,7 +956,6 @@ exports.stateTools = [
1115
956
  // Shape style properties (rectangle/ellipse/polygon/star/line)
1116
957
  fillColor: { type: 'string', description: 'Shape fill color in hex (e.g. #ffffff)' },
1117
958
  fillOpacity: { type: 'number', description: 'Shape fill opacity (0-1)' },
1118
- ...GRADIENT_FILL_SCHEMA_PROPS,
1119
959
  strokeColor: { type: 'string', description: 'Shape stroke color in hex' },
1120
960
  strokeWidth: { type: 'number', description: 'Shape stroke width (normalized; e.g. 0.005-0.02)' },
1121
961
  strokeOpacity: { type: 'number', description: 'Shape stroke opacity (0-1)' },
@@ -1324,7 +1164,6 @@ exports.stateTools = [
1324
1164
  // Shape style
1325
1165
  fillColor: { type: 'string', description: 'Shape fill color (hex)' },
1326
1166
  fillOpacity: { type: 'number', description: 'Shape fill opacity (0-1)' },
1327
- ...GRADIENT_FILL_SCHEMA_PROPS,
1328
1167
  strokeColor: { type: 'string', description: 'Shape stroke color (hex)' },
1329
1168
  strokeWidth: { type: 'number', description: 'Shape stroke width (normalized; e.g. 0.005-0.02)' },
1330
1169
  strokeOpacity: { type: 'number', description: 'Shape stroke opacity (0-1)' },
@@ -1496,7 +1335,6 @@ exports.stateTools = [
1496
1335
  paddingLeft: { type: 'number', description: 'Padding left as % of canvas' },
1497
1336
  fillColor: { type: 'string', description: 'Fill color (group background OR shape fill)' },
1498
1337
  fillOpacity: { type: 'number', description: 'Fill opacity (group background OR shape fill)' },
1499
- ...GRADIENT_FILL_SCHEMA_PROPS,
1500
1338
  cornerRadius: { type: 'number', description: 'Corner radius (group OR shape)' },
1501
1339
  // Shadow properties (all layer types)
1502
1340
  shadowOffsetX: { type: 'number', description: 'Shadow horizontal offset in CSS px' },
@@ -1769,7 +1607,6 @@ async function handleStateTool(name, args) {
1769
1607
  layers: [createDefaultBackgroundLayer(backgroundColor)],
1770
1608
  postProcesses: [],
1771
1609
  };
1772
- persistState();
1773
1610
  return {
1774
1611
  content: [
1775
1612
  {
@@ -1785,7 +1622,11 @@ async function handleStateTool(name, args) {
1785
1622
  };
1786
1623
  }
1787
1624
  case 'clear_poster': {
1788
- ensurePoster();
1625
+ if (!currentPoster) {
1626
+ return {
1627
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1628
+ };
1629
+ }
1789
1630
  const bgLayer = currentPoster.layers.find((l) => l.type === 'background');
1790
1631
  const defaultBg = createDefaultBackgroundLayer(currentPoster.canvas.backgroundColor || '#1a1a1a');
1791
1632
  currentPoster = {
@@ -1793,7 +1634,6 @@ async function handleStateTool(name, args) {
1793
1634
  layers: bgLayer ? [bgLayer] : [defaultBg],
1794
1635
  postProcesses: [],
1795
1636
  };
1796
- persistState();
1797
1637
  return {
1798
1638
  content: [{
1799
1639
  type: 'text',
@@ -1805,7 +1645,11 @@ async function handleStateTool(name, args) {
1805
1645
  };
1806
1646
  }
1807
1647
  case 'set_page_layout': {
1808
- ensurePoster();
1648
+ if (!currentPoster) {
1649
+ return {
1650
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1651
+ };
1652
+ }
1809
1653
  const layoutMode = params.layoutMode;
1810
1654
  if (layoutMode !== undefined) {
1811
1655
  if (layoutMode !== 'absolute' && layoutMode !== 'row' && layoutMode !== 'column') {
@@ -1843,7 +1687,6 @@ async function handleStateTool(name, args) {
1843
1687
  };
1844
1688
  currentPoster.canvas.padding = nextPadding;
1845
1689
  }
1846
- persistState();
1847
1690
  return {
1848
1691
  content: [
1849
1692
  {
@@ -1858,7 +1701,11 @@ async function handleStateTool(name, args) {
1858
1701
  };
1859
1702
  }
1860
1703
  case 'modify_canvas': {
1861
- ensurePoster();
1704
+ if (!currentPoster) {
1705
+ return {
1706
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1707
+ };
1708
+ }
1862
1709
  const canvas = currentPoster.canvas;
1863
1710
  if (params.aspectRatio)
1864
1711
  canvas.aspectRatio = params.aspectRatio;
@@ -1868,7 +1715,6 @@ async function handleStateTool(name, args) {
1868
1715
  canvas.canvasColor = params.canvasColor;
1869
1716
  if (params.overflow !== undefined)
1870
1717
  canvas.overflow = params.overflow;
1871
- persistState();
1872
1718
  return {
1873
1719
  content: [
1874
1720
  {
@@ -1887,7 +1733,11 @@ async function handleStateTool(name, args) {
1887
1733
  };
1888
1734
  }
1889
1735
  case 'set_background': {
1890
- ensurePoster();
1736
+ if (!currentPoster) {
1737
+ return {
1738
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1739
+ };
1740
+ }
1891
1741
  // Background is always at index 0
1892
1742
  const bgLayer = currentPoster.layers[0];
1893
1743
  if (bgLayer.type !== 'background') {
@@ -1899,8 +1749,53 @@ async function handleStateTool(name, args) {
1899
1749
  const contentType = requestedType === 'gradient' ? 'solid' : requestedType;
1900
1750
  bgLayer.contentType = contentType;
1901
1751
  if (requestedType === 'gradient') {
1902
- const gradient = buildFillFromParams(params)
1903
- ?? normalizeLinearGradientFill(DEFAULT_LINEAR_GRADIENT_FILL);
1752
+ const gradientStyle = params.gradientStyle === 'radial' ? 'radial' : 'linear';
1753
+ const explicitStops = parseGradientStopsInput(params.gradientStops, gradientStyle === 'radial' ? DEFAULT_RADIAL_GRADIENT_FILL.stops : DEFAULT_LINEAR_GRADIENT_FILL.stops);
1754
+ const gradient = gradientStyle === 'radial'
1755
+ ? normalizeRadialGradientFill({
1756
+ type: 'radial',
1757
+ center: {
1758
+ x: Number.isFinite(params.gradientCenterX)
1759
+ ? params.gradientCenterX
1760
+ : DEFAULT_RADIAL_GRADIENT_FILL.center.x,
1761
+ y: Number.isFinite(params.gradientCenterY)
1762
+ ? params.gradientCenterY
1763
+ : DEFAULT_RADIAL_GRADIENT_FILL.center.y,
1764
+ },
1765
+ radius: Number.isFinite(params.gradientRadius)
1766
+ ? params.gradientRadius
1767
+ : DEFAULT_RADIAL_GRADIENT_FILL.radius,
1768
+ stops: explicitStops ?? [
1769
+ {
1770
+ color: params.gradientStartColor || DEFAULT_RADIAL_GRADIENT_FILL.stops[0].color,
1771
+ opacity: DEFAULT_RADIAL_GRADIENT_FILL.stops[0].opacity,
1772
+ position: DEFAULT_RADIAL_GRADIENT_FILL.stops[0].position,
1773
+ },
1774
+ {
1775
+ color: params.gradientEndColor || DEFAULT_RADIAL_GRADIENT_FILL.stops[1].color,
1776
+ opacity: DEFAULT_RADIAL_GRADIENT_FILL.stops[1].opacity,
1777
+ position: DEFAULT_RADIAL_GRADIENT_FILL.stops[1].position,
1778
+ },
1779
+ ],
1780
+ })
1781
+ : normalizeLinearGradientFill({
1782
+ type: 'linear',
1783
+ angle: Number.isFinite(params.gradientAngle)
1784
+ ? params.gradientAngle
1785
+ : DEFAULT_LINEAR_GRADIENT_FILL.angle,
1786
+ stops: explicitStops ?? [
1787
+ {
1788
+ color: params.gradientStartColor || DEFAULT_LINEAR_GRADIENT_FILL.stops[0].color,
1789
+ opacity: DEFAULT_LINEAR_GRADIENT_FILL.stops[0].opacity,
1790
+ position: DEFAULT_LINEAR_GRADIENT_FILL.stops[0].position,
1791
+ },
1792
+ {
1793
+ color: params.gradientEndColor || DEFAULT_LINEAR_GRADIENT_FILL.stops[1].color,
1794
+ opacity: DEFAULT_LINEAR_GRADIENT_FILL.stops[1].opacity,
1795
+ position: DEFAULT_LINEAR_GRADIENT_FILL.stops[1].position,
1796
+ },
1797
+ ],
1798
+ });
1904
1799
  bgLayer.fill = gradient;
1905
1800
  bgLayer.solidColor = gradient.stops[0].color;
1906
1801
  currentPoster.canvas.backgroundColor = gradient.stops[0].color;
@@ -1945,7 +1840,6 @@ async function handleStateTool(name, args) {
1945
1840
  objectFit: params.objectFit || 'cover',
1946
1841
  };
1947
1842
  }
1948
- persistState();
1949
1843
  return {
1950
1844
  content: [
1951
1845
  {
@@ -1960,7 +1854,11 @@ async function handleStateTool(name, args) {
1960
1854
  };
1961
1855
  }
1962
1856
  case 'add_layer': {
1963
- ensurePoster();
1857
+ if (!currentPoster) {
1858
+ return {
1859
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
1860
+ };
1861
+ }
1964
1862
  const layerType = params.type;
1965
1863
  if (!layerType) {
1966
1864
  return {
@@ -2018,16 +1916,25 @@ async function handleStateTool(name, args) {
2018
1916
  : undefined;
2019
1917
  const autoSize = requestedAutoSize ?? 'height';
2020
1918
  if (frameWidthPx == null && textBoxWidth == null) {
2021
- const defaultFrameWidthNormalized = fontSize >= 72 ? 0.88 : 0.78;
1919
+ // Account for canvas padding so the frame width matches what the
1920
+ // layout engine will actually give us after shrink-to-fit.
1921
+ const canvasPad = currentPoster.canvas.padding || { top: 0, right: 0, bottom: 0, left: 0 };
1922
+ const canvasContentW = 1.0 - (canvasPad.left || 0) - (canvasPad.right || 0);
1923
+ const defaultFrameWidthNormalized = Math.min(fontSize >= 72 ? 0.88 : 0.78, canvasContentW * 0.95);
2022
1924
  frameWidthPx = textNormalizedToPx(defaultFrameWidthNormalized, artboardAspect, 'width');
2023
1925
  }
1926
+ // Auto-fit: shrink font if the longest word can't fit on one line
1927
+ const letterSpacing = params.letterSpacing ?? 0;
1928
+ const fittedFontSize = frameWidthPx != null
1929
+ ? autoFitFontSize(content, fontSize, letterSpacing, frameWidthPx)
1930
+ : fontSize;
2024
1931
  layer = {
2025
1932
  ...baseLayer,
2026
1933
  type: 'text',
2027
1934
  content,
2028
1935
  textType: 'frame',
2029
1936
  fontFamily: normalizeFontFamily(params.fontFamily),
2030
- fontSize,
1937
+ fontSize: fittedFontSize,
2031
1938
  fontWeight: normalizeFontWeight(params.fontWeight),
2032
1939
  color: params.color || '#ffffff',
2033
1940
  textAlign: params.textAlign || 'center',
@@ -2080,17 +1987,14 @@ async function handleStateTool(name, args) {
2080
1987
  }
2081
1988
  else if (layerType === 'rectangle') {
2082
1989
  const fillColor = normalizeHexColor(params.fillColor) || '#3b82f6';
2083
- const fillOpacity = normalizeOpacity(params.fillOpacity, 1);
2084
1990
  const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2085
- const gradientFill = buildFillFromParams(params);
2086
1991
  layer = {
2087
1992
  ...baseLayer,
2088
1993
  type: 'rectangle',
2089
1994
  style: {
2090
- fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2091
1995
  fillType: 'solid',
2092
1996
  fillColor,
2093
- fillOpacity,
1997
+ fillOpacity: normalizeOpacity(params.fillOpacity, 1),
2094
1998
  strokeColor,
2095
1999
  strokeWidth: normalizeStrokeWidth(params.strokeWidth, 0),
2096
2000
  strokeOpacity: normalizeOpacity(params.strokeOpacity, 1),
@@ -2105,19 +2009,16 @@ async function handleStateTool(name, args) {
2105
2009
  }
2106
2010
  else if (layerType === 'ellipse') {
2107
2011
  const fillColor = normalizeHexColor(params.fillColor) || '#3b82f6';
2108
- const fillOpacity = normalizeOpacity(params.fillOpacity, 1);
2109
2012
  const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2110
2013
  const w = cssPercentToSize(params.width, 20);
2111
2014
  const h = cssPercentToSize(params.height, 20);
2112
- const gradientFill = buildFillFromParams(params);
2113
2015
  layer = {
2114
2016
  ...baseLayer,
2115
2017
  type: 'ellipse',
2116
2018
  style: {
2117
- fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2118
2019
  fillType: 'solid',
2119
2020
  fillColor,
2120
- fillOpacity,
2021
+ fillOpacity: normalizeOpacity(params.fillOpacity, 1),
2121
2022
  strokeColor,
2122
2023
  strokeWidth: normalizeStrokeWidth(params.strokeWidth, 0),
2123
2024
  strokeOpacity: normalizeOpacity(params.strokeOpacity, 1),
@@ -2130,19 +2031,16 @@ async function handleStateTool(name, args) {
2130
2031
  }
2131
2032
  else if (layerType === 'polygon') {
2132
2033
  const fillColor = normalizeHexColor(params.fillColor) || '#3b82f6';
2133
- const fillOpacity = normalizeOpacity(params.fillOpacity, 1);
2134
2034
  const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2135
2035
  const w = cssPercentToSize(params.width, 20);
2136
2036
  const h = cssPercentToSize(params.height, 20);
2137
- const gradientFill = buildFillFromParams(params);
2138
2037
  layer = {
2139
2038
  ...baseLayer,
2140
2039
  type: 'polygon',
2141
2040
  style: {
2142
- fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2143
2041
  fillType: 'solid',
2144
2042
  fillColor,
2145
- fillOpacity,
2043
+ fillOpacity: normalizeOpacity(params.fillOpacity, 1),
2146
2044
  strokeColor,
2147
2045
  strokeWidth: normalizeStrokeWidth(params.strokeWidth, 0),
2148
2046
  strokeOpacity: normalizeOpacity(params.strokeOpacity, 1),
@@ -2156,19 +2054,16 @@ async function handleStateTool(name, args) {
2156
2054
  }
2157
2055
  else if (layerType === 'star') {
2158
2056
  const fillColor = normalizeHexColor(params.fillColor) || '#3b82f6';
2159
- const fillOpacity = normalizeOpacity(params.fillOpacity, 1);
2160
2057
  const strokeColor = normalizeHexColor(params.strokeColor) || '#000000';
2161
2058
  const w = cssPercentToSize(params.width, 20);
2162
2059
  const h = cssPercentToSize(params.height, 20);
2163
- const gradientFill = buildFillFromParams(params);
2164
2060
  layer = {
2165
2061
  ...baseLayer,
2166
2062
  type: 'star',
2167
2063
  style: {
2168
- fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2169
2064
  fillType: 'solid',
2170
2065
  fillColor,
2171
- fillOpacity,
2066
+ fillOpacity: normalizeOpacity(params.fillOpacity, 1),
2172
2067
  strokeColor,
2173
2068
  strokeWidth: normalizeStrokeWidth(params.strokeWidth, 0),
2174
2069
  strokeOpacity: normalizeOpacity(params.strokeOpacity, 1),
@@ -2211,7 +2106,6 @@ async function handleStateTool(name, args) {
2211
2106
  layer = baseLayer;
2212
2107
  }
2213
2108
  currentPoster.layers.push(layer);
2214
- persistState();
2215
2109
  return {
2216
2110
  content: [
2217
2111
  {
@@ -2227,7 +2121,11 @@ async function handleStateTool(name, args) {
2227
2121
  };
2228
2122
  }
2229
2123
  case 'add_group': {
2230
- ensurePoster();
2124
+ if (!currentPoster) {
2125
+ return {
2126
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
2127
+ };
2128
+ }
2231
2129
  const children = params.children;
2232
2130
  if (!children || !Array.isArray(children) || children.length === 0) {
2233
2131
  return {
@@ -2276,29 +2174,46 @@ async function handleStateTool(name, args) {
2276
2174
  const frameHeightPx = typeof childParams.frameHeightPx === 'number' ? childParams.frameHeightPx : frameHeightPxFromNormalized;
2277
2175
  const textBoxWidth = typeof childParams.textBoxWidth === 'number' ? childParams.textBoxWidth : undefined;
2278
2176
  const childFontSize = childParams.fontSize || 72;
2279
- // Default frameWidthPx for group text children (same as add_layer)
2177
+ // Default frameWidthPx — account for canvas + group padding so the
2178
+ // frame width matches the actual available space after layout shrink-to-fit.
2280
2179
  if (frameWidthPx == null && textBoxWidth == null) {
2281
- const defaultFrameWidthNormalized = childFontSize >= 72 ? 0.88 : 0.78;
2180
+ const canvasPad = currentPoster.canvas.padding || { top: 0, right: 0, bottom: 0, left: 0 };
2181
+ const canvasContentW = 1.0 - (canvasPad.left || 0) - (canvasPad.right || 0);
2182
+ const grpPadPct = params.padding ?? 0;
2183
+ const grpPadNorm = cssPercentToSize(grpPadPct, 0);
2184
+ const grpPadLR = (cssPercentToSize(params.paddingLeft ?? grpPadPct, 0)) +
2185
+ (cssPercentToSize(params.paddingRight ?? grpPadPct, 0));
2186
+ const effectiveContentW = canvasContentW - grpPadLR;
2187
+ const defaultFrameWidthNormalized = Math.min(childFontSize >= 72 ? 0.88 : 0.78, effectiveContentW * 0.98);
2282
2188
  frameWidthPx = textNormalizedToPx(defaultFrameWidthNormalized, artboardAspect, 'width');
2283
2189
  }
2190
+ const childContent = childParams.content || 'Text';
2191
+ const childLetterSpacing = childParams.letterSpacing ?? 0;
2192
+ const childAutoSize = childParams.autoSize
2193
+ ? childParams.autoSize
2194
+ : 'height';
2195
+ // Auto-fit: shrink font if the longest word can't fit on one line
2196
+ const fittedChildFontSize = frameWidthPx != null
2197
+ ? autoFitFontSize(childContent, childFontSize, childLetterSpacing, frameWidthPx)
2198
+ : childFontSize;
2284
2199
  childLayers.push({
2285
2200
  ...childBase,
2286
2201
  type: 'text',
2287
- content: childParams.content || 'Text',
2202
+ content: childContent,
2288
2203
  textType: 'frame',
2289
2204
  fontFamily: normalizeFontFamily(childParams.fontFamily),
2290
- fontSize: childFontSize,
2205
+ fontSize: fittedChildFontSize,
2291
2206
  fontWeight: normalizeFontWeight(childParams.fontWeight),
2292
2207
  color: childParams.color || '#ffffff',
2293
2208
  textAlign: childParams.textAlign || 'center',
2294
2209
  ...(childParams.verticalAlign ? { verticalAlign: childParams.verticalAlign } : {}),
2295
- letterSpacing: childParams.letterSpacing ?? 0,
2210
+ letterSpacing: childLetterSpacing,
2296
2211
  lineHeight: childParams.lineHeight ?? 1.2,
2297
2212
  textTransformMode: 'box',
2298
2213
  ...(textBoxWidth != null ? { textBoxWidth } : {}),
2299
- frameWidthPx,
2214
+ ...(frameWidthPx != null ? { frameWidthPx } : {}),
2300
2215
  ...(typeof frameHeightPx === 'number' ? { frameHeightPx } : {}),
2301
- ...(childParams.autoSize ? { autoSize: childParams.autoSize } : { autoSize: 'height' }),
2216
+ autoSize: childAutoSize,
2302
2217
  ...(parseTextTransform(childParams) ? { textTransform: parseTextTransform(childParams) } : {}),
2303
2218
  ...parseTextDecoration(childParams),
2304
2219
  });
@@ -2333,17 +2248,14 @@ async function handleStateTool(name, args) {
2333
2248
  }
2334
2249
  else if (childType === 'rectangle') {
2335
2250
  const fillColor = normalizeHexColor(childParams.fillColor) || '#3b82f6';
2336
- const fillOpacity = normalizeOpacity(childParams.fillOpacity, 1);
2337
2251
  const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2338
- const gradientFill = buildFillFromParams(childParams);
2339
2252
  childLayers.push({
2340
2253
  ...childBase,
2341
2254
  type: 'rectangle',
2342
2255
  style: {
2343
- fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2344
2256
  fillType: 'solid',
2345
2257
  fillColor,
2346
- fillOpacity,
2258
+ fillOpacity: normalizeOpacity(childParams.fillOpacity, 1),
2347
2259
  strokeColor,
2348
2260
  strokeWidth: normalizeStrokeWidth(childParams.strokeWidth, 0),
2349
2261
  strokeOpacity: normalizeOpacity(childParams.strokeOpacity, 1),
@@ -2358,19 +2270,16 @@ async function handleStateTool(name, args) {
2358
2270
  }
2359
2271
  else if (childType === 'ellipse') {
2360
2272
  const fillColor = normalizeHexColor(childParams.fillColor) || '#3b82f6';
2361
- const fillOpacity = normalizeOpacity(childParams.fillOpacity, 1);
2362
2273
  const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2363
2274
  const w = cssPercentToSize(childParams.width, 20);
2364
2275
  const h = cssPercentToSize(childParams.height, 20);
2365
- const gradientFill = buildFillFromParams(childParams);
2366
2276
  childLayers.push({
2367
2277
  ...childBase,
2368
2278
  type: 'ellipse',
2369
2279
  style: {
2370
- fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2371
2280
  fillType: 'solid',
2372
2281
  fillColor,
2373
- fillOpacity,
2282
+ fillOpacity: normalizeOpacity(childParams.fillOpacity, 1),
2374
2283
  strokeColor,
2375
2284
  strokeWidth: normalizeStrokeWidth(childParams.strokeWidth, 0),
2376
2285
  strokeOpacity: normalizeOpacity(childParams.strokeOpacity, 1),
@@ -2383,19 +2292,16 @@ async function handleStateTool(name, args) {
2383
2292
  }
2384
2293
  else if (childType === 'polygon') {
2385
2294
  const fillColor = normalizeHexColor(childParams.fillColor) || '#3b82f6';
2386
- const fillOpacity = normalizeOpacity(childParams.fillOpacity, 1);
2387
2295
  const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2388
2296
  const w = cssPercentToSize(childParams.width, 20);
2389
2297
  const h = cssPercentToSize(childParams.height, 20);
2390
- const gradientFill = buildFillFromParams(childParams);
2391
2298
  childLayers.push({
2392
2299
  ...childBase,
2393
2300
  type: 'polygon',
2394
2301
  style: {
2395
- fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2396
2302
  fillType: 'solid',
2397
2303
  fillColor,
2398
- fillOpacity,
2304
+ fillOpacity: normalizeOpacity(childParams.fillOpacity, 1),
2399
2305
  strokeColor,
2400
2306
  strokeWidth: normalizeStrokeWidth(childParams.strokeWidth, 0),
2401
2307
  strokeOpacity: normalizeOpacity(childParams.strokeOpacity, 1),
@@ -2409,19 +2315,16 @@ async function handleStateTool(name, args) {
2409
2315
  }
2410
2316
  else if (childType === 'star') {
2411
2317
  const fillColor = normalizeHexColor(childParams.fillColor) || '#3b82f6';
2412
- const fillOpacity = normalizeOpacity(childParams.fillOpacity, 1);
2413
2318
  const strokeColor = normalizeHexColor(childParams.strokeColor) || '#000000';
2414
2319
  const w = cssPercentToSize(childParams.width, 20);
2415
2320
  const h = cssPercentToSize(childParams.height, 20);
2416
- const gradientFill = buildFillFromParams(childParams);
2417
2321
  childLayers.push({
2418
2322
  ...childBase,
2419
2323
  type: 'star',
2420
2324
  style: {
2421
- fill: gradientFill ?? { type: 'solid', color: fillColor, opacity: fillOpacity },
2422
2325
  fillType: 'solid',
2423
2326
  fillColor,
2424
- fillOpacity,
2327
+ fillOpacity: normalizeOpacity(childParams.fillOpacity, 1),
2425
2328
  strokeColor,
2426
2329
  strokeWidth: normalizeStrokeWidth(childParams.strokeWidth, 0),
2427
2330
  strokeOpacity: normalizeOpacity(childParams.strokeOpacity, 1),
@@ -2511,7 +2414,6 @@ async function handleStateTool(name, args) {
2511
2414
  ...(params.cornerRadius !== undefined ? { cornerRadius: params.cornerRadius } : {}),
2512
2415
  };
2513
2416
  currentPoster.layers.push(groupLayer);
2514
- persistState();
2515
2417
  return {
2516
2418
  content: [
2517
2419
  {
@@ -2527,7 +2429,11 @@ async function handleStateTool(name, args) {
2527
2429
  };
2528
2430
  }
2529
2431
  case 'modify_layer': {
2530
- ensurePoster();
2432
+ if (!currentPoster) {
2433
+ return {
2434
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
2435
+ };
2436
+ }
2531
2437
  const layerId = params.layerId;
2532
2438
  if (!layerId) {
2533
2439
  return {
@@ -2875,19 +2781,6 @@ async function handleStateTool(name, args) {
2875
2781
  const existing = asFiniteNumber(anyLayer.style.strokeOpacity, 1);
2876
2782
  anyLayer.style.strokeOpacity = normalizeOpacity(params.strokeOpacity, existing);
2877
2783
  }
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
2784
  if (anyLayer.style.fillType === undefined && layer.type !== 'line')
2892
2785
  anyLayer.style.fillType = 'solid';
2893
2786
  if (anyLayer.style.strokeCap === undefined)
@@ -2967,7 +2860,6 @@ async function handleStateTool(name, args) {
2967
2860
  layer.transform.height = 1;
2968
2861
  }
2969
2862
  }
2970
- persistState();
2971
2863
  return {
2972
2864
  content: [
2973
2865
  {
@@ -2982,7 +2874,11 @@ async function handleStateTool(name, args) {
2982
2874
  };
2983
2875
  }
2984
2876
  case 'apply_effect': {
2985
- ensurePoster();
2877
+ if (!currentPoster) {
2878
+ return {
2879
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
2880
+ };
2881
+ }
2986
2882
  const effectId = params.effectId;
2987
2883
  if (!effectId) {
2988
2884
  return {
@@ -2991,7 +2887,6 @@ async function handleStateTool(name, args) {
2991
2887
  }
2992
2888
  if (effectId === 'none') {
2993
2889
  delete currentPoster.effect;
2994
- persistState();
2995
2890
  return {
2996
2891
  content: [
2997
2892
  {
@@ -3239,7 +3134,6 @@ async function handleStateTool(name, args) {
3239
3134
  };
3240
3135
  }
3241
3136
  currentPoster.effect = effect;
3242
- persistState();
3243
3137
  return {
3244
3138
  content: [
3245
3139
  {
@@ -3254,7 +3148,11 @@ async function handleStateTool(name, args) {
3254
3148
  };
3255
3149
  }
3256
3150
  case 'add_postprocess': {
3257
- ensurePoster();
3151
+ if (!currentPoster) {
3152
+ return {
3153
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
3154
+ };
3155
+ }
3258
3156
  const ppType = params.type;
3259
3157
  if (!ppType) {
3260
3158
  return {
@@ -3336,7 +3234,6 @@ async function handleStateTool(name, args) {
3336
3234
  settings,
3337
3235
  };
3338
3236
  currentPoster.postProcesses.push(postProcess);
3339
- persistState();
3340
3237
  // Build response with warning if dither is active
3341
3238
  const response = {
3342
3239
  success: true,
@@ -3359,7 +3256,11 @@ async function handleStateTool(name, args) {
3359
3256
  };
3360
3257
  }
3361
3258
  case 'remove_layer': {
3362
- ensurePoster();
3259
+ if (!currentPoster) {
3260
+ return {
3261
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
3262
+ };
3263
+ }
3363
3264
  const layerId = params.layerId;
3364
3265
  if (!layerId) {
3365
3266
  return {
@@ -3378,7 +3279,6 @@ async function handleStateTool(name, args) {
3378
3279
  };
3379
3280
  }
3380
3281
  currentPoster.layers.splice(layerIndex, 1);
3381
- persistState();
3382
3282
  return {
3383
3283
  content: [
3384
3284
  {
@@ -3393,7 +3293,11 @@ async function handleStateTool(name, args) {
3393
3293
  };
3394
3294
  }
3395
3295
  case 'move_layer': {
3396
- ensurePoster();
3296
+ if (!currentPoster) {
3297
+ return {
3298
+ content: [{ type: 'text', text: 'Error: No poster created. Use create_poster first.' }],
3299
+ };
3300
+ }
3397
3301
  const poster = currentPoster;
3398
3302
  const layerId = params.layerId;
3399
3303
  const direction = params.direction;
@@ -3436,7 +3340,6 @@ async function handleStateTool(name, args) {
3436
3340
  content: [{ type: 'text', text: 'Error: direction must be one of: up, down, top, bottom' }],
3437
3341
  };
3438
3342
  }
3439
- persistState();
3440
3343
  return {
3441
3344
  content: [
3442
3345
  {
@@ -3452,7 +3355,11 @@ async function handleStateTool(name, args) {
3452
3355
  };
3453
3356
  }
3454
3357
  case 'get_state': {
3455
- ensurePoster();
3358
+ if (!currentPoster) {
3359
+ return {
3360
+ content: [{ type: 'text', text: 'No poster created yet. Use create_poster first.' }],
3361
+ };
3362
+ }
3456
3363
  return {
3457
3364
  content: [
3458
3365
  {