fresnel-icon 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -0
  2. package/dist/cli.js +1086 -339
  3. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -103,6 +103,9 @@ function normalizeColorInput(input) {
103
103
  const n2 = (i) => parseInt(hex.slice(i, i + 2), 16) / 255;
104
104
  return formatColor({ space: "srgb", c: [n2(0), n2(2), n2(4), hex.length === 8 ? n2(6) : 1] });
105
105
  }
106
+ function luminance({ r, g, b }) {
107
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
108
+ }
106
109
  function mix(a, b, t) {
107
110
  return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, b: a.b + (b.b - a.b) * t, a: a.a + (b.a - a.a) * t };
108
111
  }
@@ -1309,6 +1312,107 @@ async function buildPngSet(rasterize, appearances, sizes) {
1309
1312
  return files;
1310
1313
  }
1311
1314
 
1315
+ // ../core/src/arrange.ts
1316
+ var H = CANVAS / 2;
1317
+ function layoutBoxes(doc, ctx, opts = {}) {
1318
+ const layers = [];
1319
+ const groups = [];
1320
+ for (const g of doc.groups) {
1321
+ if (!opts.includeHidden && resolveProp(g.props.hidden, ctx, false)) continue;
1322
+ const gp = resolveProp(g.props.position, ctx, null);
1323
+ const gs = gp?.scale ?? 1;
1324
+ const [gx, gy] = gp?.["translation-in-points"] ?? [0, 0];
1325
+ const toCanvas = (x, y) => ({ x: H + gx + (x - H) * gs, y: H + gy + (y - H) * gs });
1326
+ const inGroup = [];
1327
+ for (const l of g.layers) {
1328
+ if (!opts.includeHidden && resolveProp(l.props.hidden, ctx, false)) continue;
1329
+ const img = resolveProp(l.props["image-name"], ctx, "");
1330
+ const a = doc.assets[img];
1331
+ if (!a) continue;
1332
+ const p = resolveProp(l.props.position, ctx, null);
1333
+ const s = p?.scale ?? 1;
1334
+ const [tx, ty] = p?.["translation-in-points"] ?? [0, 0];
1335
+ const w = a.width * s;
1336
+ const h = a.height * s;
1337
+ const tl = toCanvas(H + tx - w / 2, H + ty - h / 2);
1338
+ const b = { kind: "layer", id: l.id, groupId: g.id, x: tl.x, y: tl.y, w: w * gs, h: h * gs };
1339
+ inGroup.push(b);
1340
+ layers.push(b);
1341
+ }
1342
+ if (inGroup.length) groups.push({ kind: "group", id: g.id, groupId: g.id, ...unionBox(inGroup) });
1343
+ }
1344
+ return { layers, groups };
1345
+ }
1346
+ function unionBox(boxes) {
1347
+ if (!boxes.length) return null;
1348
+ const x = Math.min(...boxes.map((b) => b.x));
1349
+ const y = Math.min(...boxes.map((b) => b.y));
1350
+ const x2 = Math.max(...boxes.map((b) => b.x + b.w));
1351
+ const y2 = Math.max(...boxes.map((b) => b.y + b.h));
1352
+ return { x, y, w: x2 - x, h: y2 - y };
1353
+ }
1354
+ function boxOf(doc, ref, ctx) {
1355
+ if (ref.kind === "document") return void 0;
1356
+ const geo = layoutBoxes(doc, ctx, { includeHidden: true });
1357
+ return (ref.kind === "group" ? geo.groups : geo.layers).find((b) => b.id === ref.id);
1358
+ }
1359
+ function moveBy(doc, ref, dx, dy, ctx, variant) {
1360
+ if (ref.kind === "document" || Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) return;
1361
+ let props;
1362
+ let scale = 1;
1363
+ if (ref.kind === "group") {
1364
+ const g = findGroup(doc, ref.id);
1365
+ if (!g) throw new OpError("Group not found");
1366
+ props = g.props;
1367
+ } else {
1368
+ const f = findLayer(doc, ref.id);
1369
+ if (!f) throw new OpError("Layer not found");
1370
+ props = f.layer.props;
1371
+ scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
1372
+ }
1373
+ const p = resolveProp(props.position, ctx, { scale: 1, "translation-in-points": [0, 0] }) ?? { scale: 1, "translation-in-points": [0, 0] };
1374
+ const [x, y] = p["translation-in-points"] ?? [0, 0];
1375
+ const round2 = (v) => Math.round(v * 100) / 100;
1376
+ setProp(doc, ref, "position", { ...p, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] }, variant);
1377
+ }
1378
+ function alignNodes(doc, refs, how, ctx, opts = {}) {
1379
+ const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
1380
+ if (!boxes.length) return 0;
1381
+ const ref = opts.basis === "canvas" || boxes.length === 1 ? { x: 0, y: 0, w: CANVAS, h: CANVAS } : unionBox(boxes.map((b) => b.box));
1382
+ let moved = 0;
1383
+ for (const { ref: r, box: b } of boxes) {
1384
+ let dx = 0;
1385
+ let dy = 0;
1386
+ if (how === "left") dx = ref.x - b.x;
1387
+ else if (how === "right") dx = ref.x + ref.w - (b.x + b.w);
1388
+ else if (how === "hcenter") dx = ref.x + ref.w / 2 - (b.x + b.w / 2);
1389
+ else if (how === "top") dy = ref.y - b.y;
1390
+ else if (how === "bottom") dy = ref.y + ref.h - (b.y + b.h);
1391
+ else dy = ref.y + ref.h / 2 - (b.y + b.h / 2);
1392
+ if (Math.abs(dx) < 1e-6 && Math.abs(dy) < 1e-6) continue;
1393
+ moveBy(doc, r, dx, dy, ctx, opts.variant);
1394
+ moved++;
1395
+ }
1396
+ return moved;
1397
+ }
1398
+ function distributeNodes(doc, refs, axis, ctx, opts = {}) {
1399
+ const items = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
1400
+ if (items.length < 3) return 0;
1401
+ const c = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
1402
+ items.sort((a, b) => c(a.box) - c(b.box));
1403
+ const first = c(items[0].box);
1404
+ const step = (c(items[items.length - 1].box) - first) / (items.length - 1);
1405
+ let moved = 0;
1406
+ items.forEach((it, i) => {
1407
+ if (i === 0 || i === items.length - 1) return;
1408
+ const d = first + step * i - c(it.box);
1409
+ if (Math.abs(d) < 1e-6) return;
1410
+ moveBy(doc, it.ref, axis === "horizontal" ? d : 0, axis === "vertical" ? d : 0, ctx, opts.variant);
1411
+ moved++;
1412
+ });
1413
+ return moved;
1414
+ }
1415
+
1312
1416
  // ../core/src/node.ts
1313
1417
  import { lstat, mkdir, readdir, readFile, rm, stat, writeFile, rename } from "fs/promises";
1314
1418
  import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
@@ -1382,6 +1486,56 @@ async function writeFiles(dir, files) {
1382
1486
  return written;
1383
1487
  }
1384
1488
 
1489
+ // ../render/src/content.ts
1490
+ var cache = /* @__PURE__ */ new WeakMap();
1491
+ function alphaBounds(width, height, rgba, threshold = 8) {
1492
+ let x0 = width;
1493
+ let y0 = height;
1494
+ let x1 = -1;
1495
+ let y1 = -1;
1496
+ for (let y = 0; y < height; y++) {
1497
+ const row = y * width * 4;
1498
+ for (let x = 0; x < width; x++) {
1499
+ if (rgba[row + x * 4 + 3] > threshold) {
1500
+ if (x < x0) x0 = x;
1501
+ if (x > x1) x1 = x;
1502
+ if (y < y0) y0 = y;
1503
+ if (y > y1) y1 = y;
1504
+ }
1505
+ }
1506
+ }
1507
+ if (x1 < 0) return null;
1508
+ return { x: x0 / width, y: y0 / height, w: (x1 - x0 + 1) / width, h: (y1 - y0 + 1) / height };
1509
+ }
1510
+ function setContentBox(asset, box) {
1511
+ cache.set(asset.bytes, box);
1512
+ }
1513
+ function contentBoxOf(asset) {
1514
+ if (cache.has(asset.bytes)) return cache.get(asset.bytes) ?? void 0;
1515
+ if (asset.kind === "png") {
1516
+ const bmp = decodePng(asset.bytes);
1517
+ const box = bmp ? alphaBounds(bmp.width, bmp.height, bmp.data) : null;
1518
+ cache.set(asset.bytes, box);
1519
+ return box ?? void 0;
1520
+ }
1521
+ if (asset.kind === "jpg") return { x: 0, y: 0, w: 1, h: 1 };
1522
+ return void 0;
1523
+ }
1524
+ function svgAssetsNeedingBounds(doc) {
1525
+ const out = /* @__PURE__ */ new Set();
1526
+ const hasGradient = (p) => [p?.plain?.value, ...(p?.specs ?? []).map((s) => s.value)].some((v) => !!v && typeof v === "object" && "linear-gradient" in v);
1527
+ for (const g of doc.groups)
1528
+ for (const l of g.layers) {
1529
+ if (!hasGradient(l.props.fill)) continue;
1530
+ const names = [l.props["image-name"]?.plain?.value, ...(l.props["image-name"]?.specs ?? []).map((s) => s.value)];
1531
+ for (const n2 of names) {
1532
+ const a = typeof n2 === "string" ? doc.assets[n2] : void 0;
1533
+ if (a && a.kind === "svg" && !cache.has(a.bytes)) out.add(a);
1534
+ }
1535
+ }
1536
+ return [...out];
1537
+ }
1538
+
1385
1539
  // ../render/src/mask.ts
1386
1540
  function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1387
1541
  const r = size / 2;
@@ -1407,6 +1561,57 @@ function maskPath(shape) {
1407
1561
  return SQUIRCLE;
1408
1562
  }
1409
1563
 
1564
+ // ../render/src/luma.ts
1565
+ var lumaOf = (c) => {
1566
+ const p = parseColor(c);
1567
+ return p ? luminance(toRGBA(p)) : null;
1568
+ };
1569
+ function fillLuma(fill, dark) {
1570
+ if (fill === "system-dark" || fill === "automatic" && dark) return 0.03;
1571
+ if (fill === "system-light" || fill === "automatic") return 0.9;
1572
+ if (!fill || typeof fill !== "object") return null;
1573
+ const f = fill;
1574
+ const colors = [f.solid, f["automatic-gradient"], ...Array.isArray(f["linear-gradient"]) ? f["linear-gradient"] : []].filter((x) => typeof x === "string");
1575
+ const ls = colors.map(lumaOf).filter((x) => x !== null);
1576
+ return ls.length ? ls.reduce((a, b) => a + b, 0) / ls.length : null;
1577
+ }
1578
+ var cache2 = /* @__PURE__ */ new WeakMap();
1579
+ function hexLuma(hex) {
1580
+ let h = hex.replace("#", "");
1581
+ if (h.length === 3) h = h.split("").map((x) => x + x).join("");
1582
+ if (h.length !== 6 && h.length !== 8) return null;
1583
+ const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
1584
+ return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
1585
+ }
1586
+ function assetLuma(a) {
1587
+ const hit = cache2.get(a.bytes);
1588
+ if (hit !== void 0) return hit;
1589
+ let l = 1;
1590
+ if (a.kind === "png") {
1591
+ const bmp = decodePng(a.bytes);
1592
+ if (bmp) {
1593
+ let sum = 0;
1594
+ let wsum = 0;
1595
+ const step = Math.max(1, Math.floor(bmp.width * bmp.height / 4e4));
1596
+ for (let i = 0; i < bmp.width * bmp.height; i += step) {
1597
+ const al = bmp.data[i * 4 + 3] / 255;
1598
+ if (al < 0.05) continue;
1599
+ sum += luminance({ r: bmp.data[i * 4] / 255, g: bmp.data[i * 4 + 1] / 255, b: bmp.data[i * 4 + 2] / 255, a: 1 }) * al;
1600
+ wsum += al;
1601
+ }
1602
+ if (wsum > 0) l = sum / wsum;
1603
+ }
1604
+ } else if (a.kind === "svg") {
1605
+ const text2 = new TextDecoder().decode(a.display ?? a.bytes);
1606
+ const found = [...text2.matchAll(/(?:fill|stop-color)\s*[:=]\s*["']?\s*(#[0-9a-f]{3,8})\b/gi)].map((m) => hexLuma(m[1])).filter((x) => x !== null);
1607
+ const named = [...text2.matchAll(/(?:fill|stop-color)\s*[:=]\s*["']?\s*(white|black)\b/gi)].map((m) => m[1].toLowerCase() === "white" ? 1 : 0);
1608
+ const all = [...found, ...named];
1609
+ if (all.length) l = all.reduce((x, y) => x + y, 0) / all.length;
1610
+ }
1611
+ cache2.set(a.bytes, l);
1612
+ return l;
1613
+ }
1614
+
1410
1615
  // ../render/src/compose.ts
1411
1616
  function rasterRenderOptions(ro) {
1412
1617
  if (!ro) return {};
@@ -1419,7 +1624,7 @@ function rasterRenderOptions(ro) {
1419
1624
  };
1420
1625
  }
1421
1626
  var C2 = CANVAS;
1422
- var H = C2 / 2;
1627
+ var H2 = C2 / 2;
1423
1628
  var b64Cache = /* @__PURE__ */ new WeakMap();
1424
1629
  var b64 = (bytes) => {
1425
1630
  const hit = b64Cache.get(bytes);
@@ -1505,7 +1710,7 @@ function renderSvg(doc, opts = {}) {
1505
1710
  const isClear = appearance === "clear-light" || appearance === "clear-dark";
1506
1711
  const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
1507
1712
  const tint = hexToRGBA(opts.tint ?? "#3FA7A0");
1508
- const angle = (opts.lightAngle ?? -45) * Math.PI / 180;
1713
+ const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
1509
1714
  const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
1510
1715
  const defs = new Defs(opts.idPrefix ?? "");
1511
1716
  const body = [];
@@ -1539,6 +1744,7 @@ function renderSvg(doc, opts = {}) {
1539
1744
  bgPaint = null;
1540
1745
  darken = 0;
1541
1746
  }
1747
+ const backdropLuma = (fillLuma(isClear || isTinted ? dark ? "system-dark" : "system-light" : bgFill, dark) ?? 0.9) * (1 - darken);
1542
1748
  const groups = FRONT_FIRST ? [...doc.groups].reverse() : [...doc.groups];
1543
1749
  let stackId = defs.id("s");
1544
1750
  const bgStackId = stackId;
@@ -1572,12 +1778,13 @@ function renderSvg(doc, opts = {}) {
1572
1778
  const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
1573
1779
  const w = asset.width * s;
1574
1780
  const h = asset.height * s;
1575
- placed.push({ layer: l, asset, x: H + tx - w / 2, y: H + ty - h / 2, w, h });
1781
+ placed.push({ layer: l, asset, x: H2 + tx - w / 2, y: H2 + ty - h / 2, w, h });
1576
1782
  }
1577
1783
  if (!placed.length) return null;
1578
1784
  const imgId = /* @__PURE__ */ new Map();
1579
1785
  const layerEls = [];
1580
1786
  const glassEls = [];
1787
+ const glassLumas = [];
1581
1788
  for (const p of placed) {
1582
1789
  let iid = imgId.get(p.asset.name);
1583
1790
  if (!iid) {
@@ -1592,7 +1799,9 @@ function renderSvg(doc, opts = {}) {
1592
1799
  const fill = resolveProp(p.layer.props.fill, ctx, null);
1593
1800
  const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
1594
1801
  const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
1595
- const paint = fill && fill !== "automatic" ? paintFor(fill, defs, { x: p.x, y: p.y, w: p.w, h: p.h }, dark) : null;
1802
+ const cb = contentBoxOf(p.asset);
1803
+ const fbox = cb ? { x: p.x + cb.x * p.w, y: p.y + cb.y * p.h, w: cb.w * p.w, h: cb.h * p.h } : { x: p.x, y: p.y, w: p.w, h: p.h };
1804
+ const paint = fill && fill !== "automatic" ? paintFor(fill, defs, fbox, dark) : null;
1596
1805
  let el;
1597
1806
  if (paint) {
1598
1807
  const mid = defs.id("lm");
@@ -1603,9 +1812,12 @@ function renderSvg(doc, opts = {}) {
1603
1812
  }
1604
1813
  const wrapped = `<g${opacity < 1 ? ` opacity="${n(opacity)}"` : ""}${blend ? ` style="mix-blend-mode:${blend}"` : ""}>${el}</g>`;
1605
1814
  layerEls.push(wrapped);
1606
- if (resolveProp(p.layer.props.glass, ctx, false)) glassEls.push(`<use href="#${iid}" ${place}/>`);
1815
+ if (resolveProp(p.layer.props.glass, ctx, false)) {
1816
+ glassEls.push(`<use href="#${iid}" ${place}/>`);
1817
+ glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
1818
+ }
1607
1819
  }
1608
- const gTransform = gpos ? ` transform="translate(${n(H + (gpos["translation-in-points"]?.[0] ?? 0))} ${n(H + (gpos["translation-in-points"]?.[1] ?? 0))}) scale(${n(gpos.scale ?? 1)}) translate(${-H} ${-H})"` : "";
1820
+ const gTransform = gpos ? ` transform="translate(${n(H2 + (gpos["translation-in-points"]?.[0] ?? 0))} ${n(H2 + (gpos["translation-in-points"]?.[1] ?? 0))}) scale(${n(gpos.scale ?? 1)}) translate(${-H2} ${-H2})"` : "";
1609
1821
  const contentId = defs.id("gc");
1610
1822
  defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
1611
1823
  contentIds.push(contentId);
@@ -1638,7 +1850,7 @@ function renderSvg(doc, opts = {}) {
1638
1850
  defs.add(`<filter id="${fid}" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
1639
1851
  const zoom = 1.02 + (refracts ? (refr.strength ?? 0) * 0.12 : 0.015);
1640
1852
  out.push(
1641
- `<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H} ${H}) scale(${n(zoom)}) translate(${-H} ${-H})"/></g>`
1853
+ `<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
1642
1854
  );
1643
1855
  }
1644
1856
  const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
@@ -1656,7 +1868,9 @@ function renderSvg(doc, opts = {}) {
1656
1868
  out.push(
1657
1869
  `<g${fadeMask}${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}><use href="#${contentId}"/></g>`
1658
1870
  );
1659
- const specular = resolveProp(g.props.specular, ctx, true);
1871
+ const specularSetting = resolveProp(g.props.specular, ctx, true);
1872
+ const fg = glassLumas.length ? glassLumas.reduce((a, b) => a + b, 0) / glassLumas.length : 1;
1873
+ const specular = specularSetting === true ? fg < backdropLuma ? "inside" : "outside" : specularSetting;
1660
1874
  if (effects && specular && glassEls.length) {
1661
1875
  const k = 7;
1662
1876
  const dx = n(-toLight.x * k);
@@ -1712,8 +1926,8 @@ function renderSvg(doc, opts = {}) {
1712
1926
  } else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
1713
1927
  if (effects && opts.rim !== false && mask !== "none") {
1714
1928
  const rid = defs.id("rim");
1715
- const lx = H + toLight.x * H;
1716
- const ly = H + toLight.y * H;
1929
+ const lx = H2 + toLight.x * H2;
1930
+ const ly = H2 + toLight.y * H2;
1717
1931
  defs.add(
1718
1932
  `<linearGradient id="${rid}" gradientUnits="userSpaceOnUse" x1="${n(lx)}" y1="${n(ly)}" x2="${n(C2 - lx)}" y2="${n(C2 - ly)}"><stop offset="0" stop-color="#fff" stop-opacity="${dark ? 0.55 : 0.85}"/><stop offset="0.35" stop-color="#fff" stop-opacity="0.08"/><stop offset="0.7" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="${dark ? 0.2 : 0.3}"/></linearGradient>`
1719
1933
  );
@@ -1732,11 +1946,11 @@ function cssBlend(mode) {
1732
1946
  var SHEET_ORDER = ["default", "clear-light", "tinted-light", "dark", "clear-dark", "tinted-dark"];
1733
1947
  function renderSheetSvg(doc, opts = {}) {
1734
1948
  const cell = opts.cell ?? 320;
1735
- const pad = Math.round(cell * 0.18);
1949
+ const pad2 = Math.round(cell * 0.18);
1736
1950
  const label = opts.labels !== false ? Math.round(cell * 0.12) : 0;
1737
1951
  const w = cell * 3;
1738
1952
  const h = (cell + label) * 2;
1739
- const icon = cell - pad * 2;
1953
+ const icon = cell - pad2 * 2;
1740
1954
  const parts = [];
1741
1955
  SHEET_ORDER.forEach((ap, i) => {
1742
1956
  const x = i % 3 * cell;
@@ -1744,7 +1958,7 @@ function renderSheetSvg(doc, opts = {}) {
1744
1958
  const darkBg = isDarkAppearance(ap);
1745
1959
  parts.push(`<rect x="${x}" y="${y}" width="${cell}" height="${cell + label}" fill="${darkBg ? "#14161F" : "#E3E8F0"}"/>`);
1746
1960
  const svg = renderSvg(doc, { ...opts, appearance: ap, size: icon, idPrefix: `a${i}_` });
1747
- parts.push(svg.replace("<svg ", `<svg x="${x + pad}" y="${y + pad}" `).replace(/ width="\d+" height="\d+"/, ` width="${icon}" height="${icon}"`));
1961
+ parts.push(svg.replace("<svg ", `<svg x="${x + pad2}" y="${y + pad2}" `).replace(/ width="\d+" height="\d+"/, ` width="${icon}" height="${icon}"`));
1748
1962
  if (label)
1749
1963
  parts.push(
1750
1964
  `<text x="${x + cell / 2}" y="${y + cell + label * 0.15}" text-anchor="middle" font-family="Inter, Helvetica, Arial, sans-serif" font-size="${Math.round(label * 0.5)}" font-weight="600" fill="${darkBg ? "#F2F2F5" : "#1C1C1E"}">${APPEARANCE_LABEL[ap]}</text>`
@@ -1769,11 +1983,28 @@ async function svgToPng(svg, width) {
1769
1983
  const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
1770
1984
  return new Uint8Array(r.render().asPng());
1771
1985
  }
1986
+ async function ensureContentBoxes(doc) {
1987
+ const todo = svgAssetsNeedingBounds(doc);
1988
+ if (!todo.length) return;
1989
+ const { Resvg } = await resvg();
1990
+ for (const a of todo) {
1991
+ try {
1992
+ const svg = new TextDecoder().decode(a.display ?? a.bytes);
1993
+ const png = new Resvg(svg, { fitTo: { mode: "width", value: 256 } }).render().asPng();
1994
+ const bmp = decodePng(new Uint8Array(png));
1995
+ setContentBox(a, bmp ? alphaBounds(bmp.width, bmp.height, bmp.data) : null);
1996
+ } catch {
1997
+ setContentBox(a, null);
1998
+ }
1999
+ }
2000
+ }
1772
2001
  async function renderPng(doc, opts = {}) {
2002
+ await ensureContentBoxes(doc);
1773
2003
  const size = opts.size ?? 1024;
1774
2004
  return svgToPng(renderSvg(doc, { ...opts, size }), size);
1775
2005
  }
1776
2006
  async function renderSheetPng(doc, opts = {}) {
2007
+ await ensureContentBoxes(doc);
1777
2008
  const cell = opts.cell ?? 320;
1778
2009
  return svgToPng(renderSheetSvg(doc, opts), cell * 3);
1779
2010
  }
@@ -1781,98 +2012,180 @@ function nodeRasterizer(doc, opts = {}) {
1781
2012
  return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...rasterRenderOptions(ro) });
1782
2013
  }
1783
2014
 
1784
- // ../mcp/src/tools.ts
1785
- import { mkdir as mkdir2, readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1786
- import { basename, dirname as dirname2, join as join2 } from "path";
1787
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1788
- import { z as z2 } from "zod";
1789
-
1790
- // ../mcp/src/guide.ts
1791
- var ICON_GUIDE = `# Designing Liquid Glass app icons (iOS, iPadOS, macOS, watchOS)
1792
-
1793
- Source: Apple Human Interface Guidelines, "App icons"
1794
- https://developer.apple.com/design/human-interface-guidelines/app-icons
1795
-
1796
- ## Structure
1797
- - An icon is a background plus one or more foreground layers. The system adds Liquid Glass effects (specular highlights, refraction, translucency) and adapts them to size and system version.
1798
- - Canvas: 1024\xD71024 for iOS/iPadOS/macOS (masked to a rounded rectangle) and 1088\xD71088 for watchOS (masked to a circle).
1799
- - Appearances: Default, Dark, Clear light, Clear dark, Tinted light, Tinted dark. If you don't provide a variant, the system generates one.
1800
- - Groups: Liquid Glass effects (mode, specular Off/Automatic/Inside/Outside, blur, refraction, translucency, shadow) are configured per group. Use at most 4 groups (Apple's recommendation; some actool versions reject more).
1801
- - In Icon Composer the appearances are Default, Dark and Mono (Mono covers clear and tinted); icon.json stores Mono overrides under the "tinted" key.
1802
-
1803
- ## Layers
1804
- - Provide square, unmasked layers. Never bake in rounded corners; the system masks the icon, and pre-masked layers break the specular highlights and leave jagged edges.
1805
- - Prefer clearly defined edges. Avoid soft or feathered shapes.
1806
- - Import layers fully opaque and set transparency in the tool. Varying opacity across foreground layers adds depth.
1807
- - Prefer vector art (SVG/PDF). Convert text to outlines. Use PNG for raster art and mesh gradients.
1808
- - Don't bake in highlights, drop shadows, bevels, blurs or glows. The system draws dynamic effects.
1809
-
1810
- ## Design
1811
- - Keep it simple. Use one core idea expressed with a minimal number of filled, overlapping shapes.
1812
- - Prefer a simple background, such as a solid color or gradient. A background image must be full-bleed and opaque.
1813
- - Keep primary content centered so masking (especially circles on watchOS) doesn't truncate it.
1814
- - Avoid text unless it's essential. Avoid photos, UI screenshots and replicas of Apple hardware.
1815
- - Avoid very thin lines and sharp corners; they disappear at small sizes.
1816
-
1817
- ## Appearances
1818
- - Keep the same core features across all appearances. Don't swap elements in and out.
1819
- - Base the dark icon on the light one, using complementary, less bright colors. Colored backgrounds give the most contrast in dark mode.
1820
- - Dark, clear and tinted icons are more subdued. Check that the icon stays legible and recognizable in all of them.
1821
-
1822
- ## fresnel workflow
1823
- 1. icon_create with a background (e.g. "gradient:#3D7BFF,#0E2A8A").
1824
- 2. Draw each foreground element as its own 1024\xD71024 SVG (transparent background, centered artwork) and add it with icon_add_layer.
1825
- 3. Use groups for depth: put elements that should share glass settings in one group. Index 0 is the frontmost.
1826
- 4. Set glass on layers that should catch highlights, then tune the group: translucency, blur-material, specular, shadow.
1827
- 5. Add Dark and Mono ("tinted") overrides with icon_set (appearance: "dark" or "tinted"), and platform variants (platform: "watchOS", \u2026) where layout differs.
1828
- 6. Check icon_render_sheet and icon_validate, then icon_export.
1829
- `;
1830
- function table(title, defs) {
1831
- const rows = Object.entries(defs).map(([k, d]) => `| \`${k}\` | ${d.description} | \`${JSON.stringify(d.default)}\` |`);
1832
- return `### ${title}
1833
-
1834
- | key | meaning | default when absent |
1835
- |---|---|---|
1836
- ${rows.join("\n")}
2015
+ // ../mcp/src/cliargs.ts
2016
+ var LEGACY_COMMANDS = ["build", "inspect", "validate", "render", "export", "mcp"];
2017
+ var defOf = (s) => s._zod.def;
2018
+ function unwrap(s) {
2019
+ let optional = false;
2020
+ let cur = s;
2021
+ for (; ; ) {
2022
+ const d = defOf(cur);
2023
+ if (d.type === "optional" || d.type === "default" || d.type === "nullable") {
2024
+ optional = optional || d.type !== "nullable";
2025
+ cur = d.innerType;
2026
+ } else return { schema: cur, optional };
2027
+ }
2028
+ }
2029
+ function argKind(s) {
2030
+ const { schema } = unwrap(s);
2031
+ const d = defOf(schema);
2032
+ switch (d.type) {
2033
+ case "string":
2034
+ return { type: "string" };
2035
+ case "number":
2036
+ return { type: "number", integer: !!d.checks?.some((c) => c._zod.def.format === "safeint" || c._zod.def.format === "int32") || !!schema.isInt };
2037
+ case "boolean":
2038
+ return { type: "boolean" };
2039
+ case "enum":
2040
+ return { type: "enum", values: Object.values(d.entries ?? {}) };
2041
+ case "array":
2042
+ return { type: "array", of: argKind(d.element) };
2043
+ default:
2044
+ return { type: "json" };
2045
+ }
2046
+ }
2047
+ function argsOf(t) {
2048
+ return Object.entries(t.input).map(([name, s]) => {
2049
+ const schema = s;
2050
+ const description = schema.description;
2051
+ return {
2052
+ name,
2053
+ flag: `--${name.replace(/_/g, "-")}`,
2054
+ kind: argKind(schema),
2055
+ required: !unwrap(schema).optional,
2056
+ ...description ? { description } : {}
2057
+ };
2058
+ });
2059
+ }
2060
+ var cliCommand = (t) => LEGACY_COMMANDS.includes(t.cli) ? `call ${t.id}` : t.cli;
2061
+ var kindHint = (k) => k.type === "enum" ? k.values.join("|") : k.type === "array" ? `${kindHint(k.of)},\u2026` : k.type === "json" ? "'<json>'" : k.type === "number" ? "<n>" : k.type === "boolean" ? "(flag)" : `<${k.type}>`;
2062
+ function usage(t) {
2063
+ const pos = new Set(t.positional ?? []);
2064
+ const parts = [`fresnel ${cliCommand(t)}`];
2065
+ for (const a of argsOf(t)) {
2066
+ if (pos.has(a.name)) parts.push(a.required ? `<${a.name}>` : `[<${a.name}>]`);
2067
+ }
2068
+ for (const a of argsOf(t)) {
2069
+ if (pos.has(a.name)) continue;
2070
+ const s = a.kind.type === "boolean" ? a.flag : `${a.flag} ${kindHint(a.kind)}`;
2071
+ parts.push(a.required ? s : `[${s}]`);
2072
+ }
2073
+ return parts.join(" ");
2074
+ }
2075
+ function toolHelp(t) {
2076
+ const lines = [`${t.title} (${t.id})`, "", t.description, "", `Usage: ${usage(t)}`, "", "Arguments:"];
2077
+ const pos = new Set(t.positional ?? []);
2078
+ for (const a of argsOf(t)) {
2079
+ const name = pos.has(a.name) ? `<${a.name}>` : a.flag;
2080
+ lines.push(` ${name.padEnd(22)} ${kindHint(a.kind).padEnd(14)} ${a.required ? "required" : "optional"}${a.description ? ` ${a.description}` : ""}`);
2081
+ }
2082
+ lines.push(` ${"--args".padEnd(22)} ${"'<json>'".padEnd(14)} all arguments as one JSON object (flags win)`);
2083
+ if (t.examples.length) {
2084
+ lines.push("", "Examples:");
2085
+ for (const ex of t.examples) lines.push(` # ${ex.note}`, ` ${exampleCommand(t, ex.args)}`);
2086
+ }
2087
+ lines.push("", `Output: JSON on stdout. Verify: ${t.verification.join("; ") || "\u2014"}`);
2088
+ return `${lines.join("\n")}
1837
2089
  `;
1838
2090
  }
1839
- var SCHEMA_GUIDE = `# icon.json attributes understood by fresnel
1840
-
1841
- The format isn't documented by Apple. This reference is reverse-engineered (glassmith, icon-composer-template, real Icon Composer files).
1842
- Any attribute can be overridden per appearance with icon_set(..., appearance: "dark" | "tinted").
1843
- Unknown attributes are accepted and preserved.
1844
-
1845
- ${table("Document", DOCUMENT_ATTRS)}
1846
- ${table("Group", GROUP_ATTRS)}
1847
- ${table("Layer", LAYER_ATTRS)}
2091
+ var shellQuote = (s) => /^[\w@%+=:,./#-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
2092
+ function exampleCommand(t, args) {
2093
+ const pos = t.positional ?? [];
2094
+ const parts = [`fresnel ${cliCommand(t)}`];
2095
+ for (const p of pos) if (args[p] !== void 0) parts.push(shellQuote(String(args[p])));
2096
+ for (const a of argsOf(t)) {
2097
+ const v = args[a.name];
2098
+ if (v === void 0 || pos.includes(a.name)) continue;
2099
+ if (a.kind.type === "boolean") parts.push(v ? a.flag : `--no-${a.flag.slice(2)}`);
2100
+ else if (a.kind.type === "array") parts.push(a.flag, shellQuote(v.join(",")));
2101
+ else if (a.kind.type === "json") parts.push(a.flag, shellQuote(JSON.stringify(v)));
2102
+ else parts.push(a.flag, shellQuote(String(v)));
2103
+ }
2104
+ return parts.join(" ");
2105
+ }
2106
+ function convert(k, raw, flag) {
2107
+ switch (k.type) {
2108
+ case "number": {
2109
+ const n2 = Number(raw);
2110
+ if (raw.trim() === "" || Number.isNaN(n2)) throw new Error(`${flag} expects a number, got "${raw}"`);
2111
+ return n2;
2112
+ }
2113
+ case "array":
2114
+ return raw === "" ? [] : raw.split(",").map((x) => convert(k.of, x.trim(), flag));
2115
+ case "json":
2116
+ try {
2117
+ return JSON.parse(raw);
2118
+ } catch {
2119
+ throw new Error(`${flag} expects JSON, got ${raw}`);
2120
+ }
2121
+ default:
2122
+ return raw;
2123
+ }
2124
+ }
2125
+ function parseToolArgv(t, argv) {
2126
+ const infos = argsOf(t);
2127
+ const byFlag = new Map(infos.map((a) => [a.flag, a]));
2128
+ const out = {};
2129
+ let base = {};
2130
+ const positionals = [];
2131
+ for (let i = 0; i < argv.length; i++) {
2132
+ const tok = argv[i];
2133
+ if (!tok.startsWith("--") || tok === "--") {
2134
+ positionals.push(tok);
2135
+ continue;
2136
+ }
2137
+ const eq = tok.indexOf("=");
2138
+ const flag = eq > 0 ? tok.slice(0, eq) : tok;
2139
+ const inline = eq > 0 ? tok.slice(eq + 1) : void 0;
2140
+ if (flag === "--args") {
2141
+ const raw2 = inline ?? argv[++i];
2142
+ if (raw2 === void 0) throw new Error("--args needs a JSON object");
2143
+ const v = convert({ type: "json" }, raw2, "--args");
2144
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("--args must be a JSON object");
2145
+ base = v;
2146
+ continue;
2147
+ }
2148
+ const neg = flag.startsWith("--no-") ? byFlag.get(`--${flag.slice(5)}`) : void 0;
2149
+ if (neg && neg.kind.type === "boolean") {
2150
+ out[neg.name] = false;
2151
+ continue;
2152
+ }
2153
+ const info = byFlag.get(flag);
2154
+ if (!info) throw new Error(`Unknown option ${flag} for ${t.id}. Run "fresnel ${cliCommand(t)} --help".`);
2155
+ if (info.kind.type === "boolean") {
2156
+ out[info.name] = inline === void 0 ? true : !/^(false|0|no)$/i.test(inline);
2157
+ continue;
2158
+ }
2159
+ const raw = inline ?? argv[++i];
2160
+ if (raw === void 0) throw new Error(`${flag} needs a value`);
2161
+ out[info.name] = convert(info.kind, raw, flag);
2162
+ }
2163
+ const pos = t.positional ?? [];
2164
+ if (positionals.length > pos.length) throw new Error(`Unexpected argument "${positionals[pos.length]}". Run "fresnel ${cliCommand(t)} --help".`);
2165
+ positionals.forEach((v, i) => {
2166
+ const info = infos.find((a) => a.name === pos[i]);
2167
+ if (out[info.name] === void 0) out[info.name] = convert(info.kind, v, `<${info.name}>`);
2168
+ });
2169
+ return { ...base, ...out };
2170
+ }
1848
2171
 
1849
- ### Value formats
1850
- - Color: "#RRGGBB" (stored as srgb) or Icon Composer strings: "display-p3:r,g,b,a", "srgb:r,g,b,a", "extended-gray:l,a"
1851
- - Fill: "automatic" | "system-light" | "system-dark" | "none" | {"solid": color} | {"automatic-gradient": color} | {"linear-gradient": [color, color], "orientation": {"start": {"x","y"}, "stop": {"x","y"}}}
1852
- Shorthands: "#336699" \u2192 solid, "gradient:#top,#bottom" \u2192 linear-gradient, "auto-gradient:#color" \u2192 automatic-gradient
1853
- - Position: {"scale": 1, "translation-in-points": [x, y]} (points on the 1024 canvas, y down, relative to center)
1854
- - Shadow: {"kind": "none" | "neutral" | "layer-color", "opacity": 0\u20131}
1855
- - Translucency: {"enabled": true, "value": 0\u20131}
1856
- - Blend modes: normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, soft-light, hard-light, difference, exclusion, hue, saturation, color, luminosity, plus-darker, plus-lighter
1857
- `;
2172
+ // ../mcp/src/contract.ts
2173
+ import { z as z3 } from "zod";
1858
2174
 
1859
- // ../mcp/src/tools.ts
1860
- var VERSION = "0.1.1";
2175
+ // ../mcp/src/spec.ts
2176
+ import { mkdir as mkdir2, readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
2177
+ import { basename, dirname as dirname2, join as join2 } from "path";
2178
+ import { z as z2 } from "zod";
2179
+ var CONTRACT_VERSION = "1.0";
2180
+ var tool = (def) => def;
1861
2181
  var appearanceEnum = z2.enum(APPEARANCES);
1862
2182
  var overrideEnum = z2.enum(["dark", "tinted"]);
2183
+ var platformEnum = z2.enum(["iOS", "macOS", "watchOS", "square"]);
1863
2184
  var maskEnum = z2.enum(["squircle", "circle", "none"]);
1864
- var text = (v) => ({ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) });
1865
- var ok = (...content) => ({ content });
1866
- var fail = (e) => ({ content: [text(`Error: ${e.message}`)], isError: true });
1867
- function safe(fn) {
1868
- return async (args) => {
1869
- try {
1870
- return await fn(args);
1871
- } catch (e) {
1872
- return fail(e);
1873
- }
1874
- };
1875
- }
2185
+ var pathArg = z2.string().describe('Path to the .icon bundle (".icon" is added if missing)');
2186
+ var dryRunArg = z2.boolean().optional().describe("Validate and report the result without writing anything");
2187
+ var loose = (shape) => z2.looseObject(shape);
2188
+ var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.array(z2.string()) }));
1876
2189
  function describeProps(props) {
1877
2190
  const base = {};
1878
2191
  const overrides = {};
@@ -1904,6 +2217,7 @@ function inspect(doc) {
1904
2217
  assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
1905
2218
  };
1906
2219
  }
2220
+ var outline = (doc) => inspect(doc).groups.map((g) => ({ group: g.target, layers: g.layers.map((l) => l.target) }));
1907
2221
  function targetLabel(doc, ref) {
1908
2222
  if (ref.kind === "document") return "document";
1909
2223
  if (ref.kind === "group") return nameOf(findGroup(doc, ref.id));
@@ -1913,29 +2227,41 @@ function targetLabel(doc, ref) {
1913
2227
  }
1914
2228
  return "?";
1915
2229
  }
1916
- var b642 = (u) => Buffer.from(u).toString("base64");
1917
- function createServer(ws) {
1918
- const server = new McpServer(
1919
- { name: "fresnel", version: VERSION },
1920
- {
1921
- instructions: 'fresnel edits Apple Liquid Glass app icons stored as .icon bundles (the Icon Composer format). Typical flow: icon_create \u2192 icon_add_layer (one 1024\xD71024 SVG per element) \u2192 icon_set (glass, translucency, dark/tinted overrides) \u2192 icon_render_sheet to look at all appearances \u2192 icon_validate \u2192 icon_export. Read fresnel://guide/icon-design for Apple design rules. Targets are "document", "<Group>" or "<Group>/<Layer>" (names or #index; index 0 is frontmost). Previews are approximations of the system renderer.'
1922
- }
1923
- );
1924
- server.registerTool(
1925
- "icon_create",
1926
- {
1927
- title: "Create icon",
1928
- description: 'Creates a new .icon bundle (Icon Composer format). background accepts "#RRGGBB", "gradient:#top,#bottom", "auto-gradient:#color", "automatic", "system-light", "system-dark" or a color string like "display-p3:1,0.5,0,1".',
1929
- inputSchema: {
1930
- path: z2.string().describe('Bundle path, e.g. "MyApp.icon" (".icon" is added if missing). Relative paths are inside the first allowed root.'),
1931
- background: z2.string().optional().describe("Background fill for all appearances"),
1932
- dark_background: z2.string().optional().describe("Background override for the dark appearance"),
1933
- watch: z2.boolean().optional().describe("Also target watchOS (circular). Default true"),
1934
- overwrite: z2.boolean().optional().describe("Replace an existing bundle. Default false")
1935
- },
1936
- annotations: { destructiveHint: false, idempotentHint: false }
2230
+ async function saveFile(ws, out, bytes) {
2231
+ const p = await ws.resolve(out);
2232
+ await mkdir2(dirname2(p), { recursive: true });
2233
+ await writeFile2(p, bytes);
2234
+ return p;
2235
+ }
2236
+ var inspectOutput = loose({ order: z2.string(), document: z2.record(z2.string(), z2.unknown()), groups: z2.array(z2.looseObject({ target: z2.string(), index: z2.number() })), assets: z2.array(z2.looseObject({ name: z2.string() })) });
2237
+ var EX_STAR = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 212 L594 420 L812 430 L640 566 L700 780 L512 660 L324 780 L384 566 L212 430 L430 420 Z" fill="#ffffff"/></svg>';
2238
+ var EX_DOT = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="300" fill="#7FB2FF"/></svg>';
2239
+ var TOOLS = [
2240
+ tool({
2241
+ id: "icon_create",
2242
+ cli: "create",
2243
+ positional: ["path"],
2244
+ title: "Create icon",
2245
+ summary: "Creates a new `.icon` with a background (color, gradient or system) and an optional dark background",
2246
+ description: 'Creates a new .icon bundle (Icon Composer format). background accepts "#RRGGBB", "gradient:#top,#bottom", "auto-gradient:#color", "automatic", "system-light", "system-dark" or a color string like "display-p3:1,0.5,0,1".',
2247
+ role: "create",
2248
+ capabilities: ["bundle", "background", "appearance-overrides", "platforms"],
2249
+ input: {
2250
+ path: z2.string().describe('Bundle path, e.g. "MyApp.icon" (".icon" is added if missing). Relative paths are inside the first allowed root.'),
2251
+ background: z2.string().optional().describe("Background fill for all appearances"),
2252
+ dark_background: z2.string().optional().describe("Background override for the dark appearance"),
2253
+ watch: z2.boolean().optional().describe("Also target watchOS (circular). Default true"),
2254
+ overwrite: z2.boolean().optional().describe("Replace an existing bundle. Default false"),
2255
+ dry_run: dryRunArg
1937
2256
  },
1938
- safe(async ({ path, background, dark_background, watch, overwrite }) => {
2257
+ output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
2258
+ mutates_input: true,
2259
+ idempotency_hint: "not-idempotent",
2260
+ supports_dry_run: true,
2261
+ verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
2262
+ requires_visual_verification: false,
2263
+ examples: [{ args: { path: "Demo.icon", background: "gradient:#3D7BFF,#0E2A8A", dark_background: "#101828" }, note: "New icon with a blue gradient and a dark-mode background" }],
2264
+ async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
1939
2265
  if (/\.zip$/i.test(path.trim())) throw new Error("icon_create writes an .icon folder; pass a path without .zip (use icon_export to make a zip)");
1940
2266
  const full = await ws.iconPath(path);
1941
2267
  const exists = await stat2(full).then(() => true, () => false);
@@ -1945,63 +2271,101 @@ function createServer(ws) {
1945
2271
  platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
1946
2272
  });
1947
2273
  if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
1948
- await ws.withLock(full, () => saveIcon(full, doc));
1949
- return ok(text({ created: full, ...inspect(doc) }));
1950
- })
1951
- );
1952
- server.registerTool(
1953
- "icon_inspect",
1954
- {
1955
- title: "Inspect icon",
1956
- description: "Shows the groups, layers, attributes and per-appearance overrides of an .icon bundle.",
1957
- inputSchema: { path: z2.string() },
1958
- annotations: { readOnlyHint: true }
1959
- },
1960
- safe(async ({ path }) => {
2274
+ if (!dry_run) await ws.withLock(full, () => saveIcon(full, doc));
2275
+ return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
2276
+ }
2277
+ }),
2278
+ tool({
2279
+ id: "icon_inspect",
2280
+ cli: "inspect",
2281
+ positional: ["path"],
2282
+ title: "Inspect icon",
2283
+ summary: "Lists groups, layers, attributes and every variant",
2284
+ description: "Shows the groups, layers, attributes and per-appearance overrides of an .icon bundle.",
2285
+ role: "inspect",
2286
+ capabilities: ["read"],
2287
+ input: { path: pathArg },
2288
+ output: inspectOutput,
2289
+ mutates_input: false,
2290
+ idempotency_hint: "idempotent",
2291
+ supports_dry_run: false,
2292
+ verification: [],
2293
+ requires_visual_verification: false,
2294
+ examples: [{ args: { path: "Demo.icon" }, note: "List everything in the bundle" }],
2295
+ async run(ws, { path }) {
1961
2296
  const full = await ws.iconPath(path, { mustExist: true });
1962
- return ok(text(inspect(await ws.read(full))));
1963
- })
1964
- );
1965
- server.registerTool(
1966
- "icon_add_group",
1967
- {
1968
- title: "Add group",
1969
- description: "Adds a group. Groups carry the Liquid Glass settings (translucency, blur-material, specular, shadow, lighting).",
1970
- inputSchema: {
1971
- path: z2.string(),
1972
- name: z2.string(),
1973
- index: z2.number().int().min(0).optional().describe("0 = frontmost (default)"),
1974
- props: z2.record(z2.string(), z2.any()).optional().describe('Group attributes, e.g. {"translucency": {"enabled": true, "value": 0.5}, "blur-material": 0.3}')
1975
- }
2297
+ return { data: inspect(await ws.read(full)) };
2298
+ }
2299
+ }),
2300
+ tool({
2301
+ id: "icon_add_group",
2302
+ cli: "add-group",
2303
+ positional: ["path"],
2304
+ title: "Add group",
2305
+ summary: "Adds a group (the unit that carries the glass settings)",
2306
+ description: "Adds a group. Groups carry the Liquid Glass settings (translucency, blur-material, specular, shadow, lighting).",
2307
+ role: "edit",
2308
+ capabilities: ["groups", "glass"],
2309
+ input: {
2310
+ path: pathArg,
2311
+ name: z2.string(),
2312
+ index: z2.number().int().min(0).optional().describe("0 = frontmost (default)"),
2313
+ props: z2.record(z2.string(), z2.any()).optional().describe('Group attributes, e.g. {"translucency": {"enabled": true, "value": 0.5}, "blur-material": 0.3}'),
2314
+ dry_run: dryRunArg
1976
2315
  },
1977
- safe(async ({ path, name, index, props }) => {
2316
+ output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
2317
+ mutates_input: true,
2318
+ idempotency_hint: "not-idempotent",
2319
+ supports_dry_run: true,
2320
+ verification: ["icon_inspect lists the new group"],
2321
+ requires_visual_verification: false,
2322
+ examples: [{ args: { path: "Demo.icon", name: "Back", props: { "blur-material": 0.3 } }, note: "A back group with some blur" }],
2323
+ async run(ws, { path, name, index, props, dry_run }) {
1978
2324
  const full = await ws.iconPath(path, { mustExist: true });
1979
- const res = await ws.edit(full, (doc) => {
1980
- const g = addGroup(doc, { name, ...index !== void 0 ? { index } : {} });
1981
- for (const [k, v] of Object.entries(props ?? {})) setProp(doc, { kind: "group", id: g.id }, k, v);
1982
- return { added: nameOf(g), groups: doc.groups.map(nameOf) };
1983
- });
1984
- return ok(text(res));
1985
- })
1986
- );
1987
- server.registerTool(
1988
- "icon_add_layer",
1989
- {
1990
- title: "Add layer",
1991
- description: "Adds an image layer to a group (the group is created if it does not exist). Provide either `svg` (inline SVG markup), `source` (path to an .svg/.png/.jpg file inside the allowed roots) or `png_base64`. Artwork should be a square, unmasked 1024\xD71024 canvas with a transparent background; it is placed at its natural size, centered.",
1992
- inputSchema: {
1993
- path: z2.string(),
1994
- group: z2.string().describe("Group name or #index. A missing group is created in front (index 0); use icon_move to reorder."),
1995
- svg: z2.string().optional().describe("Inline SVG markup"),
1996
- source: z2.string().optional().describe("Image file path"),
1997
- png_base64: z2.string().optional(),
1998
- file_name: z2.string().optional().describe('Name to store in Assets/, e.g. "star.svg"'),
1999
- name: z2.string().optional().describe("Layer name"),
2000
- index: z2.number().int().min(0).optional().describe("Position inside the group, 0 = frontmost (default)"),
2001
- props: z2.record(z2.string(), z2.any()).optional().describe('Layer attributes, e.g. {"glass": true, "opacity": 0.8, "fill": "#FFFFFF", "position": {"scale": 0.8, "translation-in-points": [0, -40]}}')
2002
- }
2325
+ const data = await ws.edit(
2326
+ full,
2327
+ (doc) => {
2328
+ const g = addGroup(doc, { name, ...index !== void 0 ? { index } : {} });
2329
+ for (const [k, v] of Object.entries(props ?? {})) setProp(doc, { kind: "group", id: g.id }, k, v);
2330
+ return { added: nameOf(g), groups: doc.groups.map(nameOf) };
2331
+ },
2332
+ { dryRun: !!dry_run }
2333
+ );
2334
+ return { data };
2335
+ }
2336
+ }),
2337
+ tool({
2338
+ id: "icon_add_layer",
2339
+ cli: "add-layer",
2340
+ positional: ["path"],
2341
+ title: "Add layer",
2342
+ summary: "Adds a layer from inline SVG, an image file or PNG data",
2343
+ description: "Adds an image layer to a group (the group is created if it does not exist). Provide either `svg` (inline SVG markup), `source` (path to an .svg/.png/.jpg file inside the allowed roots) or `png_base64`. Artwork should be a square, unmasked 1024\xD71024 canvas with a transparent background; it is placed at its natural size, centered.",
2344
+ role: "edit",
2345
+ capabilities: ["layers", "images", "glass"],
2346
+ input: {
2347
+ path: pathArg,
2348
+ group: z2.string().describe("Group name or #index. A missing group is created in front (index 0); use icon_move to reorder."),
2349
+ svg: z2.string().optional().describe("Inline SVG markup"),
2350
+ source: z2.string().optional().describe("Image file path"),
2351
+ png_base64: z2.string().optional(),
2352
+ file_name: z2.string().optional().describe('Name to store in Assets/, e.g. "star.svg"'),
2353
+ name: z2.string().optional().describe("Layer name"),
2354
+ index: z2.number().int().min(0).optional().describe("Position inside the group, 0 = frontmost (default)"),
2355
+ props: z2.record(z2.string(), z2.any()).optional().describe('Layer attributes, e.g. {"glass": true, "opacity": 0.8, "fill": "#FFFFFF", "position": {"scale": 0.8, "translation-in-points": [0, -40]}}'),
2356
+ dry_run: dryRunArg
2003
2357
  },
2004
- safe(async ({ path, group, svg, source, png_base64, file_name, name, index, props }) => {
2358
+ output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
2359
+ mutates_input: true,
2360
+ idempotency_hint: "not-idempotent",
2361
+ supports_dry_run: true,
2362
+ verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
2363
+ requires_visual_verification: true,
2364
+ examples: [
2365
+ { args: { path: "Demo.icon", group: "Front", svg: EX_STAR, name: "Star" }, note: 'Inline SVG; the "Front" group is created in front' },
2366
+ { args: { path: "Demo.icon", group: "Back", svg: EX_DOT, name: "Dot", props: { opacity: 0.8 } }, note: "A second layer behind it" }
2367
+ ],
2368
+ async run(ws, { path, group, svg, source, png_base64, file_name, name, index, props, dry_run }) {
2005
2369
  const given = [svg, source, png_base64].filter((x) => x !== void 0).length;
2006
2370
  if (given !== 1) throw new Error("Provide exactly one of svg, source or png_base64");
2007
2371
  let bytes;
@@ -2020,129 +2384,242 @@ function createServer(ws) {
2020
2384
  const asset = makeAsset(fname, bytes);
2021
2385
  if (asset.kind === "other") throw new Error("Unsupported image. Use SVG, PNG or JPEG.");
2022
2386
  const full = await ws.iconPath(path, { mustExist: true });
2023
- const res = await ws.edit(full, (doc) => {
2024
- let gid;
2025
- try {
2026
- const ref = resolvePath(doc, group);
2027
- if (ref.kind !== "group") throw new Error("`group` must name a group");
2028
- gid = ref.id;
2029
- } catch (e) {
2030
- const g = group.trim();
2031
- if (/^#\d+$/.test(g) || g.includes("/") || !/not found/i.test(e.message)) throw e;
2032
- gid = addGroup(doc, { name: g }).id;
2033
- }
2034
- const l = addLayer(doc, gid, asset, {
2035
- ...name ? { name } : {},
2036
- ...index !== void 0 ? { index } : {},
2037
- ...props ? { props } : {}
2038
- });
2039
- return {
2040
- added: targetLabel(doc, { kind: "layer", id: l.id }),
2041
- size: `${asset.width}\xD7${asset.height}`,
2042
- warnings: validate(doc).filter((i) => i.ref?.kind === "layer" && i.ref.id === l.id).map((i) => `${i.id}: ${i.message}`)
2043
- };
2044
- });
2045
- return ok(text(res));
2046
- })
2047
- );
2048
- server.registerTool(
2049
- "icon_set",
2050
- {
2051
- title: "Set attributes",
2052
- description: 'Sets attributes on the document, a group or a layer. Pass `appearance` ("dark", or "tinted" = Mono in Icon Composer) and/or `platform` ("iOS", "macOS", "watchOS", "square" = iOS+macOS) to write a variant for only that case. Set a value to null to remove it (or to remove the override when `appearance` is given). See fresnel://schema/icon-json for keys and formats.',
2053
- inputSchema: {
2054
- path: z2.string(),
2055
- target: z2.string().describe('"document", "<Group>" or "<Group>/<Layer>" (names or #index)'),
2056
- props: z2.record(z2.string(), z2.any()).describe('kebab-case keys, e.g. {"translucency": {"enabled": true, "value": 0.6}, "specular": true}'),
2057
- appearance: overrideEnum.optional(),
2058
- platform: z2.enum(["iOS", "macOS", "watchOS", "square"]).optional()
2059
- }
2387
+ const data = await ws.edit(
2388
+ full,
2389
+ (doc) => {
2390
+ let gid;
2391
+ try {
2392
+ const ref = resolvePath(doc, group);
2393
+ if (ref.kind !== "group") throw new Error("`group` must name a group");
2394
+ gid = ref.id;
2395
+ } catch (e) {
2396
+ const g = group.trim();
2397
+ if (/^#\d+$/.test(g) || g.includes("/") || !/not found/i.test(e.message)) throw e;
2398
+ gid = addGroup(doc, { name: g }).id;
2399
+ }
2400
+ const l = addLayer(doc, gid, asset, {
2401
+ ...name ? { name } : {},
2402
+ ...index !== void 0 ? { index } : {},
2403
+ ...props ? { props } : {}
2404
+ });
2405
+ return {
2406
+ added: targetLabel(doc, { kind: "layer", id: l.id }),
2407
+ size: `${asset.width}\xD7${asset.height}`,
2408
+ warnings: validate(doc).filter((i) => i.ref?.kind === "layer" && i.ref.id === l.id).map((i) => `${i.id}: ${i.message}`)
2409
+ };
2410
+ },
2411
+ { dryRun: !!dry_run }
2412
+ );
2413
+ return { data };
2414
+ }
2415
+ }),
2416
+ tool({
2417
+ id: "icon_set",
2418
+ cli: "set",
2419
+ positional: ["path"],
2420
+ title: "Set attributes",
2421
+ summary: "Sets attributes for all appearances, or a dark / tinted / platform variant",
2422
+ description: 'Sets attributes on the document, a group or a layer. Pass `appearance` ("dark", or "tinted" = Mono in Icon Composer) and/or `platform` ("iOS", "macOS", "watchOS", "square" = iOS+macOS) to write a variant for only that case. Set a value to null to remove it (or to remove the override when `appearance` is given). See fresnel://schema/icon-json for keys and formats.',
2423
+ role: "edit",
2424
+ capabilities: ["attributes", "appearance-overrides", "platform-variants", "glass"],
2425
+ input: {
2426
+ path: pathArg,
2427
+ target: z2.string().describe('"document", "<Group>" or "<Group>/<Layer>" (names or #index)'),
2428
+ props: z2.record(z2.string(), z2.any()).describe('kebab-case keys, e.g. {"translucency": {"enabled": true, "value": 0.6}, "specular": true}'),
2429
+ appearance: overrideEnum.optional(),
2430
+ platform: platformEnum.optional(),
2431
+ dry_run: dryRunArg
2060
2432
  },
2061
- safe(async ({ path, target, props, appearance, platform }) => {
2433
+ output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
2434
+ mutates_input: true,
2435
+ idempotency_hint: "idempotent",
2436
+ supports_dry_run: true,
2437
+ verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
2438
+ requires_visual_verification: true,
2439
+ examples: [
2440
+ { args: { path: "Demo.icon", target: "Front", props: { translucency: { enabled: true, value: 0.4 }, specular: true } }, note: "Glass settings on a group" },
2441
+ { args: { path: "Demo.icon", target: "Front/Star", props: { fill: "#FFD60A" }, appearance: "dark" }, note: "A dark-mode color for one layer" }
2442
+ ],
2443
+ async run(ws, { path, target, props, appearance, platform, dry_run }) {
2062
2444
  const full = await ws.iconPath(path, { mustExist: true });
2063
- const res = await ws.edit(full, (doc) => {
2064
- const ref = resolvePath(doc, target);
2065
- const variant = appearance || platform ? { ...appearance ? { appearance } : {}, ...platform ? { idiom: platform } : {} } : void 0;
2066
- for (const [k, v] of Object.entries(props)) setProp(doc, ref, k, v, variant);
2067
- return { target: targetLabel(doc, ref), now: describeProps(nodeOf(doc, ref).props) };
2068
- });
2069
- return ok(text(res));
2070
- })
2071
- );
2072
- server.registerTool(
2073
- "icon_remove",
2074
- {
2075
- title: "Remove group or layer",
2076
- description: "Removes a group or a layer. Unused assets are deleted from Assets/.",
2077
- inputSchema: { path: z2.string(), target: z2.string() },
2078
- annotations: { destructiveHint: true }
2079
- },
2080
- safe(async ({ path, target }) => {
2445
+ const data = await ws.edit(
2446
+ full,
2447
+ (doc) => {
2448
+ const ref = resolvePath(doc, target);
2449
+ const variant = appearance || platform ? { ...appearance ? { appearance } : {}, ...platform ? { idiom: platform } : {} } : void 0;
2450
+ for (const [k, v] of Object.entries(props)) setProp(doc, ref, k, v, variant);
2451
+ return { target: targetLabel(doc, ref), now: describeProps(nodeOf(doc, ref).props) };
2452
+ },
2453
+ { dryRun: !!dry_run }
2454
+ );
2455
+ return { data };
2456
+ }
2457
+ }),
2458
+ tool({
2459
+ id: "icon_move",
2460
+ cli: "move",
2461
+ positional: ["path"],
2462
+ title: "Reorder",
2463
+ summary: "Reorders groups and layers, or moves a layer to another group",
2464
+ description: "Moves a group or layer to a new index (0 = frontmost). Layers can move to another group with to_group.",
2465
+ role: "edit",
2466
+ capabilities: ["groups", "layers", "order"],
2467
+ input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
2468
+ output: loose({ groups: groupsOutline }),
2469
+ mutates_input: true,
2470
+ idempotency_hint: "idempotent",
2471
+ supports_dry_run: true,
2472
+ verification: ["`groups` lists the new order (front to back)"],
2473
+ requires_visual_verification: false,
2474
+ examples: [{ args: { path: "Demo.icon", target: "Back", index: 1 }, note: "Keep the Back group behind Front" }],
2475
+ async run(ws, { path, target, index, to_group, dry_run }) {
2081
2476
  const full = await ws.iconPath(path, { mustExist: true });
2082
- const res = await ws.edit(full, (doc) => {
2083
- const ref = resolvePath(doc, target);
2084
- const label = targetLabel(doc, ref);
2085
- removeNode(doc, ref);
2086
- return { removed: label, groups: inspect(doc).groups.map((g) => ({ group: g.target, layers: g.layers.map((l) => l.target) })) };
2087
- });
2088
- return ok(text(res));
2089
- })
2090
- );
2091
- server.registerTool(
2092
- "icon_move",
2093
- {
2094
- title: "Reorder",
2095
- description: "Moves a group or layer to a new index (0 = frontmost). Layers can move to another group with to_group.",
2096
- inputSchema: { path: z2.string(), target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional() }
2477
+ const data = await ws.edit(
2478
+ full,
2479
+ (doc) => {
2480
+ const ref = resolvePath(doc, target);
2481
+ let gid;
2482
+ if (to_group) {
2483
+ const g = resolvePath(doc, to_group);
2484
+ if (g.kind !== "group") throw new Error("to_group must be a group");
2485
+ gid = g.id;
2486
+ }
2487
+ moveNode(doc, ref, index, gid);
2488
+ return { groups: outline(doc) };
2489
+ },
2490
+ { dryRun: !!dry_run }
2491
+ );
2492
+ return { data };
2493
+ }
2494
+ }),
2495
+ tool({
2496
+ id: "icon_align",
2497
+ cli: "align",
2498
+ positional: ["path"],
2499
+ title: "Align or distribute",
2500
+ summary: "Aligns or distributes groups or layers (to each other or to the canvas)",
2501
+ description: 'Aligns groups or layers by their bounding boxes (left, hcenter, right, top, vcenter, bottom), or spaces 3+ items evenly (distribute-horizontal, distribute-vertical). With one target, or basis "canvas", items align to the 1024 pt canvas. Only positions change.',
2502
+ role: "edit",
2503
+ capabilities: ["layout", "align", "distribute", "platform-variants"],
2504
+ input: {
2505
+ path: pathArg,
2506
+ targets: z2.array(z2.string()).min(1).describe('"<Group>" or "<Group>/<Layer>" (names or #index); all of the same kind'),
2507
+ how: z2.enum(["left", "hcenter", "right", "top", "vcenter", "bottom", "distribute-horizontal", "distribute-vertical"]),
2508
+ basis: z2.enum(["selection", "canvas"]).optional().describe("Default: selection (canvas when there is one target)"),
2509
+ platform: platformEnum.optional().describe("Write the positions as a platform variant"),
2510
+ dry_run: dryRunArg
2097
2511
  },
2098
- safe(async ({ path, target, index, to_group }) => {
2512
+ output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
2513
+ mutates_input: true,
2514
+ idempotency_hint: "idempotent",
2515
+ supports_dry_run: true,
2516
+ verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
2517
+ requires_visual_verification: true,
2518
+ examples: [{ args: { path: "Demo.icon", targets: ["Front/Star"], how: "vcenter" }, note: "Center one layer vertically on the canvas" }],
2519
+ async run(ws, { path, targets, how, basis, platform, dry_run }) {
2099
2520
  const full = await ws.iconPath(path, { mustExist: true });
2100
- const res = await ws.edit(full, (doc) => {
2101
- const ref = resolvePath(doc, target);
2102
- let gid;
2103
- if (to_group) {
2104
- const g = resolvePath(doc, to_group);
2105
- if (g.kind !== "group") throw new Error("to_group must be a group");
2106
- gid = g.id;
2107
- }
2108
- moveNode(doc, ref, index, gid);
2109
- return inspect(doc).groups.map((g) => ({ group: g.target, layers: g.layers.map((l) => l.target) }));
2110
- });
2111
- return ok(text(res));
2112
- })
2113
- );
2114
- server.registerTool(
2115
- "icon_validate",
2116
- {
2117
- title: "Validate",
2118
- description: "Checks the icon against the schema and Apple HIG recommendations (square unmasked layers, opaque background, no text, variants, \u2026).",
2119
- inputSchema: { path: z2.string() },
2120
- annotations: { readOnlyHint: true }
2121
- },
2122
- safe(async ({ path }) => {
2521
+ const data = await ws.edit(
2522
+ full,
2523
+ (doc) => {
2524
+ const refs = targets.map((t) => resolvePath(doc, t));
2525
+ if (refs.some((r) => r.kind === "document")) throw new Error("targets must be groups or layers");
2526
+ if (new Set(refs.map((r) => r.kind)).size > 1) throw new Error("targets must all be groups or all be layers");
2527
+ const ctx = { appearance: "default", idiom: platform === "square" || !platform ? "iOS" : platform };
2528
+ const variant = platform ? { idiom: platform } : void 0;
2529
+ const moved = how.startsWith("distribute-") ? distributeNodes(doc, refs, how === "distribute-horizontal" ? "horizontal" : "vertical", ctx, { variant }) : alignNodes(doc, refs, how, ctx, { ...basis ? { basis } : {}, variant });
2530
+ return { moved, positions: refs.map((r, i) => ({ target: targets[i], box: boxOf(doc, r, ctx) })) };
2531
+ },
2532
+ { dryRun: !!dry_run }
2533
+ );
2534
+ return { data };
2535
+ }
2536
+ }),
2537
+ tool({
2538
+ id: "icon_remove",
2539
+ cli: "remove",
2540
+ positional: ["path"],
2541
+ title: "Remove group or layer",
2542
+ summary: "Deletes a group or layer (unused assets go too)",
2543
+ description: "Removes a group or a layer. Unused assets are deleted from Assets/.",
2544
+ role: "edit",
2545
+ capabilities: ["groups", "layers"],
2546
+ input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
2547
+ output: loose({ removed: z2.string(), groups: groupsOutline }),
2548
+ mutates_input: true,
2549
+ idempotency_hint: "not-idempotent",
2550
+ destructive: true,
2551
+ supports_dry_run: true,
2552
+ verification: ["`groups` no longer lists the target"],
2553
+ requires_visual_verification: false,
2554
+ examples: [{ args: { path: "Demo.icon", target: "Back/Dot", dry_run: true }, note: "Preview a removal without writing" }],
2555
+ async run(ws, { path, target, dry_run }) {
2556
+ const full = await ws.iconPath(path, { mustExist: true });
2557
+ const data = await ws.edit(
2558
+ full,
2559
+ (doc) => {
2560
+ const ref = resolvePath(doc, target);
2561
+ const label = targetLabel(doc, ref);
2562
+ removeNode(doc, ref);
2563
+ return { removed: label, groups: outline(doc) };
2564
+ },
2565
+ { dryRun: !!dry_run }
2566
+ );
2567
+ return { data };
2568
+ }
2569
+ }),
2570
+ tool({
2571
+ id: "icon_validate",
2572
+ cli: "validate",
2573
+ positional: ["path"],
2574
+ title: "Validate",
2575
+ summary: "Runs the icon checks (schema and Apple HIG)",
2576
+ description: "Checks the icon against the schema and Apple HIG recommendations (square unmasked layers, opaque background, no text, variants, \u2026).",
2577
+ role: "check",
2578
+ capabilities: ["validate", "hig"],
2579
+ input: { path: pathArg },
2580
+ output: loose({
2581
+ summary: z2.looseObject({ errors: z2.number(), warnings: z2.number(), infos: z2.number() }),
2582
+ issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
2583
+ }),
2584
+ mutates_input: false,
2585
+ idempotency_hint: "idempotent",
2586
+ supports_dry_run: false,
2587
+ verification: ['No issue with severity "error"'],
2588
+ requires_visual_verification: false,
2589
+ examples: [{ args: { path: "Demo.icon" }, note: "Run the V-01\u2026V-10 checks" }],
2590
+ async run(ws, { path }) {
2123
2591
  const full = await ws.iconPath(path, { mustExist: true });
2124
2592
  const issues = validate(await ws.read(full)).map(({ ref: _ref, ...i }) => i);
2125
- return ok(text({ summary: summarizeIssues(issues), issues }));
2126
- })
2127
- );
2128
- server.registerTool(
2129
- "icon_render",
2130
- {
2131
- title: "Render preview",
2132
- description: "Renders an approximate preview PNG of one appearance and returns it as an image. Optionally saves it with `out`.",
2133
- inputSchema: {
2134
- path: z2.string(),
2135
- appearance: appearanceEnum.optional().describe('Default: "default". Mono in Icon Composer = clear-light / clear-dark / tinted-light / tinted-dark'),
2136
- platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
2137
- size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
2138
- mask: maskEnum.optional(),
2139
- tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2140
- light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top, -45 = top-left (default)"),
2141
- out: z2.string().optional().describe("Save the PNG to this path")
2142
- },
2143
- annotations: { readOnlyHint: true }
2593
+ return { data: { summary: summarizeIssues(issues), issues } };
2594
+ }
2595
+ }),
2596
+ tool({
2597
+ id: "icon_render",
2598
+ cli: "render",
2599
+ positional: ["path"],
2600
+ title: "Render preview",
2601
+ summary: "Returns a PNG of one appearance as an image the model can see",
2602
+ description: "Renders an approximate preview PNG of one appearance and returns it as an image. Optionally saves it with `out`.",
2603
+ role: "render",
2604
+ capabilities: ["preview", "appearances", "platforms"],
2605
+ input: {
2606
+ path: pathArg,
2607
+ appearance: appearanceEnum.optional().describe('Default: "default". Mono in Icon Composer = clear-light / clear-dark / tinted-light / tinted-dark'),
2608
+ platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
2609
+ size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
2610
+ mask: maskEnum.optional(),
2611
+ tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2612
+ light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
2613
+ out: z2.string().optional().describe("Save the PNG to this path")
2144
2614
  },
2145
- safe(async ({ path, appearance, platform, size, mask, tint, light_angle, out }) => {
2615
+ output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
2616
+ mutates_input: false,
2617
+ idempotency_hint: "idempotent",
2618
+ supports_dry_run: false,
2619
+ verification: ["Look at the returned image"],
2620
+ requires_visual_verification: true,
2621
+ examples: [{ args: { path: "Demo.icon", appearance: "dark", size: 128, out: "Demo-dark.png" }, note: "Dark preview saved as a file" }],
2622
+ async run(ws, { path, appearance, platform, size, mask, tint, light_angle, out }) {
2146
2623
  const full = await ws.iconPath(path, { mustExist: true });
2147
2624
  const doc = await ws.read(full);
2148
2625
  const png = await renderPng(doc, {
@@ -2154,43 +2631,62 @@ function createServer(ws) {
2154
2631
  ...light_angle !== void 0 ? { lightAngle: light_angle } : {}
2155
2632
  });
2156
2633
  const saved = out ? await saveFile(ws, out, png) : void 0;
2157
- return ok({ type: "image", data: b642(png), mimeType: "image/png" }, text({ appearance: appearance ?? "default", ...saved ? { saved } : {} }));
2158
- })
2159
- );
2160
- server.registerTool(
2161
- "icon_render_sheet",
2162
- {
2163
- title: "Render all appearances",
2164
- description: "Renders the six appearances (Default, Clear light, Tinted light / Dark, Clear dark, Tinted dark) side by side and returns the PNG.",
2165
- inputSchema: {
2166
- path: z2.string(),
2167
- cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
2168
- tint: z2.string().optional(),
2169
- out: z2.string().optional()
2170
- },
2171
- annotations: { readOnlyHint: true }
2634
+ return { data: { appearance: appearance ?? "default", ...saved ? { saved } : {} }, image: png };
2635
+ }
2636
+ }),
2637
+ tool({
2638
+ id: "icon_render_sheet",
2639
+ cli: "render-sheet",
2640
+ positional: ["path"],
2641
+ title: "Render all appearances",
2642
+ summary: "Returns all six appearances side by side as one PNG",
2643
+ description: "Renders the six appearances (Default, Clear light, Tinted light / Dark, Clear dark, Tinted dark) side by side and returns the PNG.",
2644
+ role: "render",
2645
+ capabilities: ["preview", "appearances"],
2646
+ input: {
2647
+ path: pathArg,
2648
+ cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
2649
+ tint: z2.string().optional(),
2650
+ out: z2.string().optional()
2172
2651
  },
2173
- safe(async ({ path, cell, tint, out }) => {
2652
+ output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
2653
+ mutates_input: false,
2654
+ idempotency_hint: "idempotent",
2655
+ supports_dry_run: false,
2656
+ verification: ["Look at the returned image: every appearance should stay legible"],
2657
+ requires_visual_verification: true,
2658
+ examples: [{ args: { path: "Demo.icon", cell: 120 }, note: "Compare all six appearances" }],
2659
+ async run(ws, { path, cell, tint, out }) {
2174
2660
  const full = await ws.iconPath(path, { mustExist: true });
2175
2661
  const png = await renderSheetPng(await ws.read(full), { cell: cell ?? 240, ...tint ? { tint } : {} });
2176
2662
  const saved = out ? await saveFile(ws, out, png) : void 0;
2177
- return ok({ type: "image", data: b642(png), mimeType: "image/png" }, ...saved ? [text({ saved })] : []);
2178
- })
2179
- );
2180
- server.registerTool(
2181
- "icon_export",
2182
- {
2183
- title: "Export",
2184
- description: 'Exports the icon. Formats: "icon" (copy of the .icon bundle for Xcode 26+ / Icon Composer), "icns" (macOS), "appiconset" (Xcode asset catalog with Dark and Tinted variants), "png" (PNG set per appearance and size). Raster formats use fresnel\u2019s approximate renderer. Assets.car requires Apple\u2019s actool on macOS and is not produced here.',
2185
- inputSchema: {
2186
- path: z2.string(),
2187
- out_dir: z2.string(),
2188
- formats: z2.array(z2.enum(["icon", "icns", "appiconset", "png"])).min(1),
2189
- sizes: z2.array(z2.number().int().min(16).max(2048)).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2190
- appearances: z2.array(appearanceEnum).optional().describe("PNG appearances, default all six")
2191
- }
2663
+ return { data: { appearances: [...APPEARANCES], ...saved ? { saved } : {} }, image: png };
2664
+ }
2665
+ }),
2666
+ tool({
2667
+ id: "icon_export",
2668
+ cli: "export",
2669
+ positional: ["path"],
2670
+ title: "Export",
2671
+ summary: "Writes `.icon`, `.icns`, `AppIcon.appiconset` and PNG sets",
2672
+ description: 'Exports the icon. Formats: "icon" (copy of the .icon bundle for Xcode 26+ / Icon Composer), "icns" (macOS), "appiconset" (Xcode asset catalog with Dark and Tinted variants), "png" (PNG set per appearance and size). Raster formats use fresnel\u2019s approximate renderer. Assets.car requires Apple\u2019s actool on macOS and is not produced here.',
2673
+ role: "export",
2674
+ capabilities: ["export", "icns", "appiconset", "png"],
2675
+ input: {
2676
+ path: pathArg,
2677
+ out_dir: z2.string(),
2678
+ formats: z2.array(z2.enum(["icon", "icns", "appiconset", "png"])).min(1),
2679
+ sizes: z2.array(z2.number().int().min(16).max(2048)).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2680
+ appearances: z2.array(appearanceEnum).optional().describe("PNG appearances, default all six")
2192
2681
  },
2193
- safe(async ({ path, out_dir, formats, sizes, appearances }) => {
2682
+ output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
2683
+ mutates_input: false,
2684
+ idempotency_hint: "idempotent",
2685
+ supports_dry_run: false,
2686
+ verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
2687
+ requires_visual_verification: false,
2688
+ examples: [{ args: { path: "Demo.icon", out_dir: "build", formats: ["icon", "icns", "appiconset"] }, note: "Everything Xcode needs" }],
2689
+ async run(ws, { path, out_dir, formats, sizes, appearances }) {
2194
2690
  const full = await ws.iconPath(path, { mustExist: true });
2195
2691
  const outDir = await ws.resolve(out_dir);
2196
2692
  const doc = await ws.read(full);
@@ -2214,14 +2710,208 @@ function createServer(ws) {
2214
2710
  if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
2215
2711
  if (formats.includes("png"))
2216
2712
  written.push(
2217
- ...await writeFiles(
2218
- join2(outDir, `${name}-png`),
2219
- await buildPngSet(raster, appearances ?? [...APPEARANCES], sizes ?? [1024, 512, 256, 128, 64, 32, 16])
2220
- )
2713
+ ...await writeFiles(join2(outDir, `${name}-png`), await buildPngSet(raster, appearances ?? [...APPEARANCES], sizes ?? [1024, 512, 256, 128, 64, 32, 16]))
2221
2714
  );
2222
- return ok(text({ out_dir: outDir, files: written.length, written }));
2223
- })
2715
+ return { data: { out_dir: outDir, files: written.length, written } };
2716
+ }
2717
+ })
2718
+ ];
2719
+ var toolById = (id) => TOOLS.find((t) => t.id === id);
2720
+ var toolByCli = (name) => TOOLS.find((t) => t.cli === name);
2721
+ async function runTool(ws, t, args) {
2722
+ const parsed = z2.object(t.input).strict().safeParse(args ?? {});
2723
+ if (!parsed.success) throw new Error(`Invalid arguments for ${t.id}: ${z2.prettifyError(parsed.error)}`);
2724
+ return t.run(ws, parsed.data);
2725
+ }
2726
+
2727
+ // ../mcp/src/version.ts
2728
+ var VERSION = "0.2.0";
2729
+
2730
+ // ../mcp/src/contract.ts
2731
+ var RESOURCES = [
2732
+ { uri: "fresnel://guide/icon-design", title: "Liquid Glass icon design guide" },
2733
+ { uri: "fresnel://schema/icon-json", title: "icon.json attribute reference" },
2734
+ { uri: "fresnel://contract", title: "SPEC contract (this document)" }
2735
+ ];
2736
+ var PROMPTS = [{ name: "design_app_icon", title: "Design an app icon" }];
2737
+ var LEGACY_HELP = {
2738
+ build: "fresnel build <spec.json> -o <Name.icon> \u2014 build a bundle from a JSON spec (fixtures/sample.spec.json)",
2739
+ inspect: "fresnel inspect <Name.icon> \u2014 same output as icon_inspect",
2740
+ validate: "fresnel validate <Name.icon> [--json] [--strict] \u2014 exit 1 on errors (--strict: also warnings)",
2741
+ render: "fresnel render <Name.icon> [-a <appearance>] [-s <px>] [--mask \u2026] [--tint \u2026] [--sheet] [-o out.png] \u2014 writes a PNG file",
2742
+ export: "fresnel export <Name.icon> -o <dir> [--formats \u2026] [--sizes \u2026] [--appearances \u2026] [--tint \u2026] [--force]",
2743
+ mcp: "fresnel mcp [--root <dir>]\u2026 \u2014 start the MCP server on stdio"
2744
+ };
2745
+ var jsonSchema = (s, io) => {
2746
+ const { $schema: _drop, ...rest } = z3.toJSONSchema(s, { io, unrepresentable: "any" });
2747
+ return rest;
2748
+ };
2749
+ function attrTable(attrs) {
2750
+ return Object.fromEntries(
2751
+ Object.entries(attrs).map(([k, a]) => [k, { schema: jsonSchema(a.schema, "input"), default: a.default ?? null, description: a.description }])
2752
+ );
2753
+ }
2754
+ function toolSpec(t) {
2755
+ return {
2756
+ id: t.id,
2757
+ title: t.title,
2758
+ summary: t.summary,
2759
+ description: t.description,
2760
+ role: t.role,
2761
+ capabilities: t.capabilities,
2762
+ input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
2763
+ output_schema: jsonSchema(t.output, "output"),
2764
+ mutates_input: t.mutates_input,
2765
+ idempotency_hint: t.idempotency_hint,
2766
+ destructive: !!t.destructive,
2767
+ supports_dry_run: t.supports_dry_run,
2768
+ supports_json: true,
2769
+ returns_image: t.role === "render",
2770
+ verification: t.verification,
2771
+ requires_visual_verification: t.requires_visual_verification,
2772
+ cli: usage(t),
2773
+ examples: t.examples.map((e) => ({ ...e, cli: exampleCommand(t, e.args) }))
2774
+ };
2775
+ }
2776
+ function buildContract() {
2777
+ const capability_map = {};
2778
+ for (const t of TOOLS) for (const c of t.capabilities) (capability_map[c] ??= []).push(t.id);
2779
+ return {
2780
+ contract_version: CONTRACT_VERSION,
2781
+ name: "fresnel",
2782
+ version: VERSION,
2783
+ description: "Create, edit, preview and export Apple Liquid Glass app icons (.icon bundles, the Icon Composer format).",
2784
+ conventions: {
2785
+ targets: '"document", "<Group>" or "<Group>/<Layer>" (names or #index; index 0 is frontmost)',
2786
+ order: "groups and layers are listed front to back",
2787
+ errors: "MCP: isError with a readable message. CLI: message on stderr, exit code 1",
2788
+ cli: `argument names are the input names with "_" \u2192 "-"; arrays are comma separated; objects are JSON; --args '<json>' passes everything at once`,
2789
+ previews: "approximations of Apple's renderer; the final check is Icon Composer / Xcode 26 on a Mac"
2790
+ },
2791
+ stability: {
2792
+ compatible: ["new tools", "new optional arguments", "new output keys", "new enum values", "wording of descriptions, examples and verification"],
2793
+ breaking: ["removing or renaming a tool or argument", "optional \u2192 required", "changing an argument type", "removing an enum value", "removing an output key"],
2794
+ rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
2795
+ },
2796
+ tools: TOOLS.map(toolSpec),
2797
+ capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a.localeCompare(b))),
2798
+ resources: RESOURCES,
2799
+ prompts: PROMPTS,
2800
+ icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
2801
+ cli_legacy_commands: LEGACY_HELP
2802
+ };
2803
+ }
2804
+
2805
+ // ../mcp/src/tools.ts
2806
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2807
+ import { z as z4 } from "zod";
2808
+
2809
+ // ../mcp/src/guide.ts
2810
+ var ICON_GUIDE = `# Designing Liquid Glass app icons (iOS, iPadOS, macOS, watchOS)
2811
+
2812
+ Source: Apple Human Interface Guidelines, "App icons"
2813
+ https://developer.apple.com/design/human-interface-guidelines/app-icons
2814
+
2815
+ ## Structure
2816
+ - An icon is a background plus one or more foreground layers. The system adds Liquid Glass effects (specular highlights, refraction, translucency) and adapts them to size and system version.
2817
+ - Canvas: 1024\xD71024 for iOS/iPadOS/macOS (masked to a rounded rectangle) and 1088\xD71088 for watchOS (masked to a circle).
2818
+ - Appearances: Default, Dark, Clear light, Clear dark, Tinted light, Tinted dark. If you don't provide a variant, the system generates one.
2819
+ - Groups: Liquid Glass effects (mode, specular Off/Automatic/Inside/Outside, blur, refraction, translucency, shadow) are configured per group. Use at most 4 groups (Apple's recommendation; some actool versions reject more).
2820
+ - In Icon Composer the appearances are Default, Dark and Mono (Mono covers clear and tinted); icon.json stores Mono overrides under the "tinted" key.
2821
+
2822
+ ## Layers
2823
+ - Provide square, unmasked layers. Never bake in rounded corners; the system masks the icon, and pre-masked layers break the specular highlights and leave jagged edges.
2824
+ - Prefer clearly defined edges. Avoid soft or feathered shapes.
2825
+ - Import layers fully opaque and set transparency in the tool. Varying opacity across foreground layers adds depth.
2826
+ - Prefer vector art (SVG/PDF). Convert text to outlines. Use PNG for raster art and mesh gradients.
2827
+ - Don't bake in highlights, drop shadows, bevels, blurs or glows. The system draws dynamic effects.
2828
+
2829
+ ## Design
2830
+ - Keep it simple. Use one core idea expressed with a minimal number of filled, overlapping shapes.
2831
+ - Prefer a simple background, such as a solid color or gradient. A background image must be full-bleed and opaque.
2832
+ - Keep primary content centered so masking (especially circles on watchOS) doesn't truncate it.
2833
+ - Avoid text unless it's essential. Avoid photos, UI screenshots and replicas of Apple hardware.
2834
+ - Avoid very thin lines and sharp corners; they disappear at small sizes.
2835
+
2836
+ ## Appearances
2837
+ - Keep the same core features across all appearances. Don't swap elements in and out.
2838
+ - Base the dark icon on the light one, using complementary, less bright colors. Colored backgrounds give the most contrast in dark mode.
2839
+ - Dark, clear and tinted icons are more subdued. Check that the icon stays legible and recognizable in all of them.
2840
+
2841
+ ## fresnel workflow
2842
+ 1. icon_create with a background (e.g. "gradient:#3D7BFF,#0E2A8A").
2843
+ 2. Draw each foreground element as its own 1024\xD71024 SVG (transparent background, centered artwork) and add it with icon_add_layer.
2844
+ 3. Use groups for depth: put elements that should share glass settings in one group. Index 0 is the frontmost.
2845
+ 4. Set glass on layers that should catch highlights, then tune the group: translucency, blur-material, specular, shadow.
2846
+ 5. Add Dark and Mono ("tinted") overrides with icon_set (appearance: "dark" or "tinted"), and platform variants (platform: "watchOS", \u2026) where layout differs.
2847
+ 6. Check icon_render_sheet and icon_validate, then icon_export.
2848
+ `;
2849
+ function table(title, defs) {
2850
+ const rows = Object.entries(defs).map(([k, d]) => `| \`${k}\` | ${d.description} | \`${JSON.stringify(d.default)}\` |`);
2851
+ return `### ${title}
2852
+
2853
+ | key | meaning | default when absent |
2854
+ |---|---|---|
2855
+ ${rows.join("\n")}
2856
+ `;
2857
+ }
2858
+ var SCHEMA_GUIDE = `# icon.json attributes understood by fresnel
2859
+
2860
+ The format isn't documented by Apple. This reference is reverse-engineered (glassmith, icon-composer-template, real Icon Composer files).
2861
+ Any attribute can be overridden per appearance with icon_set(..., appearance: "dark" | "tinted").
2862
+ Unknown attributes are accepted and preserved.
2863
+
2864
+ ${table("Document", DOCUMENT_ATTRS)}
2865
+ ${table("Group", GROUP_ATTRS)}
2866
+ ${table("Layer", LAYER_ATTRS)}
2867
+
2868
+ ### Value formats
2869
+ - Color: "#RRGGBB" (stored as srgb) or Icon Composer strings: "display-p3:r,g,b,a", "srgb:r,g,b,a", "extended-gray:l,a"
2870
+ - Fill: "automatic" | "system-light" | "system-dark" | "none" | {"solid": color} | {"automatic-gradient": color} | {"linear-gradient": [color, color], "orientation": {"start": {"x","y"}, "stop": {"x","y"}}}
2871
+ Shorthands: "#336699" \u2192 solid, "gradient:#top,#bottom" \u2192 linear-gradient, "auto-gradient:#color" \u2192 automatic-gradient
2872
+ - Position: {"scale": 1, "translation-in-points": [x, y]} (points on the 1024 canvas, y down, relative to center)
2873
+ - Shadow: {"kind": "none" | "neutral" | "layer-color", "opacity": 0\u20131}
2874
+ - Translucency: {"enabled": true, "value": 0\u20131}
2875
+ - Blend modes: normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, soft-light, hard-light, difference, exclusion, hue, saturation, color, luminosity, plus-darker, plus-lighter
2876
+ `;
2877
+
2878
+ // ../mcp/src/tools.ts
2879
+ var text = (v) => ({ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) });
2880
+ var fail = (e) => ({ content: [text(`Error: ${e.message}`)], isError: true });
2881
+ var b642 = (u) => Buffer.from(u).toString("base64");
2882
+ function createServer(ws) {
2883
+ const server = new McpServer(
2884
+ { name: "fresnel", version: VERSION },
2885
+ {
2886
+ instructions: 'fresnel edits Apple Liquid Glass app icons stored as .icon bundles (the Icon Composer format). Typical flow: icon_create \u2192 icon_add_layer (one 1024\xD71024 SVG per element) \u2192 icon_set (glass, translucency, dark/tinted overrides) \u2192 icon_render_sheet to look at all appearances \u2192 icon_validate \u2192 icon_export. Read fresnel://guide/icon-design for Apple design rules. Targets are "document", "<Group>" or "<Group>/<Layer>" (names or #index; index 0 is frontmost). Previews are approximations of the system renderer.'
2887
+ }
2224
2888
  );
2889
+ for (const t of TOOLS) {
2890
+ server.registerTool(
2891
+ t.id,
2892
+ {
2893
+ title: t.title,
2894
+ description: t.description,
2895
+ inputSchema: t.input,
2896
+ outputSchema: t.output,
2897
+ annotations: {
2898
+ readOnlyHint: !t.mutates_input,
2899
+ destructiveHint: !!t.destructive,
2900
+ idempotentHint: t.idempotency_hint === "idempotent",
2901
+ openWorldHint: false
2902
+ }
2903
+ },
2904
+ async (args) => {
2905
+ try {
2906
+ const { data, image } = await runTool(ws, t, args);
2907
+ const content = image ? [{ type: "image", data: b642(image), mimeType: "image/png" }, text(data)] : [text(data)];
2908
+ return { content, structuredContent: data };
2909
+ } catch (e) {
2910
+ return fail(e);
2911
+ }
2912
+ }
2913
+ );
2914
+ }
2225
2915
  server.registerResource(
2226
2916
  "icon-design-guide",
2227
2917
  "fresnel://guide/icon-design",
@@ -2234,15 +2924,21 @@ function createServer(ws) {
2234
2924
  { title: "icon.json attribute reference", description: "Keys, value formats and defaults fresnel understands", mimeType: "text/markdown" },
2235
2925
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: SCHEMA_GUIDE }] })
2236
2926
  );
2927
+ server.registerResource(
2928
+ "contract",
2929
+ "fresnel://contract",
2930
+ { title: "SPEC contract", description: "Machine-readable contract: every tool with input/output JSON Schema, roles, verification and examples, plus the icon.json attribute table", mimeType: "application/json" },
2931
+ async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(buildContract(), null, 2) }] })
2932
+ );
2237
2933
  server.registerPrompt(
2238
2934
  "design_app_icon",
2239
2935
  {
2240
2936
  title: "Design an app icon",
2241
2937
  description: "Step-by-step workflow to design a Liquid Glass app icon from an app description",
2242
2938
  argsSchema: {
2243
- app: z2.string().describe("What the app does"),
2244
- path: z2.string().optional().describe("Where to save the .icon bundle"),
2245
- style: z2.string().optional().describe("Colors / mood / constraints")
2939
+ app: z4.string().describe("What the app does"),
2940
+ path: z4.string().optional().describe("Where to save the .icon bundle"),
2941
+ style: z4.string().optional().describe("Colors / mood / constraints")
2246
2942
  }
2247
2943
  },
2248
2944
  ({ app, path, style }) => ({
@@ -2271,12 +2967,6 @@ Use the fresnel tools:
2271
2967
  );
2272
2968
  return server;
2273
2969
  }
2274
- async function saveFile(ws, out, bytes) {
2275
- const p = await ws.resolve(out);
2276
- await mkdir2(dirname2(p), { recursive: true });
2277
- await writeFile2(p, bytes);
2278
- return p;
2279
- }
2280
2970
 
2281
2971
  // ../mcp/src/workspace.ts
2282
2972
  import { realpath, stat as stat3 } from "fs/promises";
@@ -2284,8 +2974,11 @@ import { dirname as dirname3, isAbsolute as isAbsolute2, relative as relative2,
2284
2974
  var Workspace = class {
2285
2975
  roots;
2286
2976
  locks = /* @__PURE__ */ new Map();
2287
- constructor(roots) {
2977
+ /** With `unrestricted` (the CLI without --root) any path is allowed; relative paths still start at the first root. */
2978
+ unrestricted;
2979
+ constructor(roots, opts = {}) {
2288
2980
  this.roots = (roots.length ? roots : [process.cwd()]).map((r) => resolve2(r));
2981
+ this.unrestricted = !!opts.unrestricted;
2289
2982
  }
2290
2983
  /** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
2291
2984
  async resolve(p, opts = {}) {
@@ -2311,6 +3004,7 @@ var Workspace = class {
2311
3004
  }
2312
3005
  }
2313
3006
  async allowed(full) {
3007
+ if (this.unrestricted) return true;
2314
3008
  for (const r of this.roots) {
2315
3009
  let root = r;
2316
3010
  try {
@@ -2335,12 +3029,12 @@ var Workspace = class {
2335
3029
  return full;
2336
3030
  }
2337
3031
  /** Serializes edits per file so concurrent tool calls can't clobber each other. */
2338
- async edit(path, fn) {
3032
+ async edit(path, fn, opts = {}) {
2339
3033
  return this.withLock(path, async () => {
2340
3034
  if (path.endsWith(".zip")) throw new Error("Editing .icon.zip is not supported; unzip it first or use the .icon folder");
2341
3035
  const doc = await loadIcon(path);
2342
3036
  const result = await fn(doc);
2343
- await saveIcon(path, doc);
3037
+ if (!opts.dryRun) await saveIcon(path, doc);
2344
3038
  return result;
2345
3039
  });
2346
3040
  }
@@ -2360,7 +3054,8 @@ var Workspace = class {
2360
3054
  };
2361
3055
 
2362
3056
  // src/cli.ts
2363
- var HELP = `fresnel ${VERSION} \u2014 Liquid Glass app icon tools
3057
+ var pad = (s, n2) => s.length >= n2 ? `${s} ` : s.padEnd(n2);
3058
+ var HELP = `fresnel ${VERSION} \u2014 Liquid Glass app icon tools (contract ${CONTRACT_VERSION})
2364
3059
 
2365
3060
  Usage
2366
3061
  fresnel build <spec.json> -o <Name.icon> Build an .icon bundle from a JSON spec
@@ -2376,9 +3071,37 @@ Usage
2376
3071
 
2377
3072
  Render options: --mask squircle|circle|none, --tint #RRGGBB (tinted appearances)
2378
3073
 
3074
+ Tools (same arguments as the MCP server; JSON on stdout; "fresnel <command> --help" for details)
3075
+ ${TOOLS.map((t) => ` ${pad(`fresnel ${cliCommand(t)}`, 31)}${t.summary.replace(/`/g, "")}`).join("\n")}
3076
+
3077
+ fresnel contract [--json] Print the SPEC contract (every tool, its schemas, roles and examples)
3078
+ Options for tools: --args '<json>' (all arguments at once), --root <dir> (limit file access)
3079
+
2379
3080
  Appearances: ${APPEARANCES.join(", ")}
2380
3081
  Docs: https://github.com/kajisho5/fresnel
2381
3082
  `;
3083
+ async function callTool(t, argv) {
3084
+ if (argv.includes("--help") || argv.includes("-h")) {
3085
+ process.stdout.write(toolHelp(t));
3086
+ return;
3087
+ }
3088
+ const roots = [];
3089
+ const rest = [];
3090
+ for (let i = 0; i < argv.length; i++) {
3091
+ const a = argv[i];
3092
+ if (a === "--root" || a === "-r") {
3093
+ const v = argv[++i];
3094
+ if (!v) throw new Error("--root needs a directory");
3095
+ roots.push(v);
3096
+ } else if (a.startsWith("--root=")) roots.push(a.slice(7));
3097
+ else rest.push(a);
3098
+ }
3099
+ const ws = new Workspace(roots, { unrestricted: roots.length === 0 });
3100
+ const { data, image } = await runTool(ws, t, parseToolArgv(t, rest));
3101
+ if (image && !data.saved) process.stderr.write("fresnel: the PNG was not saved; pass --out <file.png> to keep it\n");
3102
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
3103
+ `);
3104
+ }
2382
3105
  async function build(specPath, out) {
2383
3106
  const spec = JSON.parse(await readFile3(specPath, "utf8"));
2384
3107
  const base = dirname4(resolve3(specPath));
@@ -2437,6 +3160,30 @@ async function main() {
2437
3160
  console.log(VERSION);
2438
3161
  return;
2439
3162
  }
3163
+ if (cmd === "contract") {
3164
+ if (rest.some((a) => a !== "--json")) throw new Error("Usage: fresnel contract [--json]");
3165
+ process.stdout.write(`${JSON.stringify(buildContract(), null, 2)}
3166
+ `);
3167
+ return;
3168
+ }
3169
+ if (cmd === "call") {
3170
+ const [id, ...args] = rest;
3171
+ if (!id || id === "--help" || id === "-h") {
3172
+ process.stdout.write(`Usage: fresnel call <tool_id> [args]
3173
+ Tools: ${TOOLS.map((t2) => t2.id).join(", ")}
3174
+ `);
3175
+ return;
3176
+ }
3177
+ const t = toolById(id) ?? toolById(`icon_${id.replace(/-/g, "_")}`);
3178
+ if (!t) throw new Error(`Unknown tool "${id}". Tools: ${TOOLS.map((x) => x.id).join(", ")}`);
3179
+ await callTool(t, args);
3180
+ return;
3181
+ }
3182
+ const short = toolByCli(cmd);
3183
+ if (short && cliCommand(short) === short.cli) {
3184
+ await callTool(short, rest);
3185
+ return;
3186
+ }
2440
3187
  const { values, positionals } = parseArgs({
2441
3188
  args: rest,
2442
3189
  allowPositionals: true,