fresnel-icon 0.1.0 → 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 +1265 -386
  3. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -23,6 +23,8 @@ function appearanceKeys(a) {
23
23
  case "tinted-light":
24
24
  case "tinted-dark":
25
25
  return ["tinted"];
26
+ case "default":
27
+ return ["light"];
26
28
  default:
27
29
  return [];
28
30
  }
@@ -101,6 +103,9 @@ function normalizeColorInput(input) {
101
103
  const n2 = (i) => parseInt(hex.slice(i, i + 2), 16) / 255;
102
104
  return formatColor({ space: "srgb", c: [n2(0), n2(2), n2(4), hex.length === 8 ? n2(6) : 1] });
103
105
  }
106
+ function luminance({ r, g, b }) {
107
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
108
+ }
104
109
  function mix(a, b, t) {
105
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 };
106
111
  }
@@ -275,9 +280,10 @@ var BlendModeSchema = z.enum(BLEND_MODES);
275
280
  var PositionSchema = z.object({ scale: z.number().positive(), "translation-in-points": z.tuple([z.number(), z.number()]) }).loose();
276
281
  var ShadowSchema = z.object({ kind: z.enum(["none", "neutral", "layer-color"]), opacity: z.number().min(0).max(1) }).loose();
277
282
  var TranslucencySchema = z.object({ enabled: z.boolean(), value: z.number().min(0).max(1) }).loose();
278
- var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(0).max(1), depth: z.number().min(0).max(1) }).loose();
283
+ var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(-1).max(1), depth: z.number().min(-1).max(1) }).loose();
279
284
  var SupportedPlatformsSchema = z.object({
280
- squares: z.union([z.literal("shared"), z.array(z.string())]).optional(),
285
+ // "shared", ["iOS"] or {"platforms": ["iOS"]} (both list forms appear in community tools)
286
+ squares: z.union([z.literal("shared"), z.array(z.string()), z.object({ platforms: z.array(z.string()) }).loose()]).optional(),
281
287
  circles: z.array(z.string()).optional()
282
288
  }).loose();
283
289
  var unit = z.number().min(0).max(1);
@@ -421,14 +427,29 @@ var DROP_ELEMENTS = /* @__PURE__ */ new Set([
421
427
  "animatecolor",
422
428
  "discard",
423
429
  "handler",
424
- "listener",
425
- "a"
430
+ "listener"
426
431
  ]);
432
+ var UNWRAP_ELEMENTS = /* @__PURE__ */ new Set(["a"]);
433
+ function decodeForCheck(v) {
434
+ return v.replace(/&#x([0-9a-f]+);?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/&#(\d+);?/g, (_m, d) => String.fromCodePoint(Number(d) % 1114112)).replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1").replace(/\s+/g, "").toLowerCase();
435
+ }
436
+ function expandInternalEntities(svg) {
437
+ const doctype = /<!DOCTYPE[^\[>]*\[([\s\S]*?)\]\s*>/i.exec(svg);
438
+ if (!doctype) return svg;
439
+ const ents = /* @__PURE__ */ new Map();
440
+ for (const m of doctype[1].matchAll(/<!ENTITY\s+([\w.-]+)\s+(?:"([^"]*)"|'([^']*)')\s*>/g)) {
441
+ const v = m[2] ?? m[3] ?? "";
442
+ if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
443
+ }
444
+ const body = svg.slice(0, doctype.index) + svg.slice(doctype.index + doctype[0].length);
445
+ return body.replace(/&([\w.-]+);/g, (m, name) => ents.get(name) ?? m);
446
+ }
427
447
  function safeUrlRef(v) {
428
448
  const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
429
449
  return t.startsWith("#") || /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]*$/i.test(t);
430
450
  }
431
- function cleanCss(css) {
451
+ function cleanCss(css0) {
452
+ const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
432
453
  return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
433
454
  }
434
455
  function cleanAttrs(raw) {
@@ -446,9 +467,13 @@ function cleanAttrs(raw) {
446
467
  if (lname === "href" || lname.endsWith(":href") || lname === "src") {
447
468
  if (!safeUrlRef(value)) continue;
448
469
  }
449
- if (/javascript\s*:|vbscript\s*:/i.test(value)) continue;
470
+ const plain = decodeForCheck(value);
471
+ if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
450
472
  let v = value;
451
- if (lname === "style" || /url\s*\(/i.test(v)) v = cleanCss(v);
473
+ if (lname === "style" || /url\(/.test(plain)) {
474
+ v = cleanCss(v);
475
+ if (/url\((?!['"]?#)/.test(decodeForCheck(v).replace(/url\(['"]?data:image\/(png|jpe?g|gif|webp);base64,/g, "url(#"))) continue;
476
+ }
452
477
  out.push(`${name}="${v.replace(/"/g, "&quot;")}"`);
453
478
  }
454
479
  return out.length ? " " + out.join(" ") : "";
@@ -518,6 +543,7 @@ function sanitizeOnce(svg) {
518
543
  i = j + 1;
519
544
  const [, close, tag, attrs, selfClose] = m;
520
545
  const local = tag.toLowerCase().replace(/^.*:/, "");
546
+ if (UNWRAP_ELEMENTS.has(local) && !skip.length) continue;
521
547
  if (close) {
522
548
  if (skip.length) {
523
549
  if (skip[skip.length - 1] === local) skip.pop();
@@ -542,7 +568,7 @@ function sanitizeOnce(svg) {
542
568
  return out;
543
569
  }
544
570
  function sanitizeSvg(svg) {
545
- let s = svg;
571
+ let s = expandInternalEntities(svg);
546
572
  for (let n2 = 0; n2 < 5; n2++) {
547
573
  const next = sanitizeOnce(s);
548
574
  if (next === s) break;
@@ -561,8 +587,9 @@ function svgFacts(svg) {
561
587
  function makeAsset(name, bytes) {
562
588
  const kind = sniffKind(name, bytes);
563
589
  if (kind === "svg") {
564
- const text2 = sanitizeSvg(td.decode(bytes));
565
- return { name, kind, bytes: te.encode(text2), ...svgSize(text2) };
590
+ const original = td.decode(bytes);
591
+ const text2 = sanitizeSvg(original);
592
+ return { name, kind, bytes, ...text2 !== original ? { display: te.encode(text2) } : {}, ...svgSize(text2) };
566
593
  }
567
594
  if (kind === "png") return { name, kind, bytes, ...pngSize(bytes) };
568
595
  if (kind === "jpg") return { name, kind, bytes, ...jpgSize(bytes) };
@@ -716,7 +743,28 @@ function encodePngRgb(width, height, rgba, bg = [0, 0, 0]) {
716
743
  }
717
744
  function flattenPng(png, bg = [0, 0, 0]) {
718
745
  const bmp = decodePng(png);
719
- return bmp ? encodePngRgb(bmp.width, bmp.height, bmp.data, bg) : png;
746
+ if (!bmp) throw new Error("Could not flatten the PNG (unsupported encoding)");
747
+ return tagSrgb(encodePngRgb(bmp.width, bmp.height, bmp.data, bg));
748
+ }
749
+ function tagSrgb(png) {
750
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
751
+ if (png.length < 33 || sig.some((b, i) => png[i] !== b)) return png;
752
+ let o = 8;
753
+ const dv = new DataView(png.buffer, png.byteOffset, png.byteLength);
754
+ while (o + 8 <= png.length) {
755
+ const len = dv.getUint32(o);
756
+ const type = String.fromCharCode(png[o + 4], png[o + 5], png[o + 6], png[o + 7]);
757
+ if (type === "sRGB" || type === "iCCP") return png;
758
+ if (type === "IDAT") break;
759
+ o += 12 + len;
760
+ }
761
+ const ihdrEnd = 8 + 12 + 13;
762
+ const srgb = chunk("sRGB", new Uint8Array([0]));
763
+ const out = new Uint8Array(png.length + srgb.length);
764
+ out.set(png.subarray(0, ihdrEnd), 0);
765
+ out.set(srgb, ihdrEnd);
766
+ out.set(png.subarray(ihdrEnd), ihdrEnd + srgb.length);
767
+ return out;
720
768
  }
721
769
 
722
770
  // ../core/src/bundle.ts
@@ -753,6 +801,10 @@ function serializeIconJson(doc) {
753
801
  const json = writeProps(doc.props, doc.order, { groups });
754
802
  return JSON.stringify(json, null, 2).replace(/^(\s*"[^"]*"):/gm, "$1 :") + "\n";
755
803
  }
804
+ function isSafeBundlePath(rel) {
805
+ if (!rel || rel.length > 512 || rel.startsWith("/") || /[\\\u0000-\u001f:]/.test(rel)) return false;
806
+ return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
807
+ }
756
808
  function readBundle(files) {
757
809
  const jsonKey = Object.keys(files).find((k) => k === "icon.json" || k.endsWith("/icon.json"));
758
810
  if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
@@ -763,10 +815,11 @@ function readBundle(files) {
763
815
  for (const [k, v] of Object.entries(files)) {
764
816
  if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
765
817
  const rel = k.slice(prefix.length);
818
+ if (!isSafeBundlePath(rel)) continue;
766
819
  if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
767
820
  const name = rel.slice("Assets/".length);
768
- if (name && !name.startsWith(".")) assets[name] = makeAsset(name, v);
769
- } else if (!rel.split("/").some((p) => p === "__MACOSX" || p === ".DS_Store")) {
821
+ if (name) assets[name] = makeAsset(name, v);
822
+ } else if (!rel.split("/").some((p) => p === "__MACOSX")) {
770
823
  extra[rel] = v;
771
824
  }
772
825
  }
@@ -892,12 +945,7 @@ function uniqueName(existing, wanted0) {
892
945
  while (existing.includes(`${wanted} ${i}`)) i++;
893
946
  return `${wanted} ${i}`;
894
947
  }
895
- var MAX_GROUPS = 4;
896
- function assertGroupRoom(doc) {
897
- if (doc.groups.length >= MAX_GROUPS) throw new OpError(`An icon can have up to ${MAX_GROUPS} groups. Add the layer to an existing group instead.`);
898
- }
899
948
  function addGroup(doc, opts = {}) {
900
- assertGroupRoom(doc);
901
949
  const name = uniqueName(doc.groups.map(nameOf), opts.name ?? "Group");
902
950
  const g = { id: newId("g"), props: {}, order: [], layers: [] };
903
951
  setPropValue(g.props, "name", name);
@@ -935,7 +983,8 @@ function addLayer(doc, groupId, asset, opts = {}) {
935
983
  const l = { id: newId("l"), props: {}, order: [] };
936
984
  setPropValue(l.props, "image-name", imageName);
937
985
  setPropValue(l.props, "name", uniqueName(g.layers.map(nameOf), base));
938
- l.order = ["image-name", "name"];
986
+ setPropValue(l.props, "glass", true);
987
+ l.order = ["glass", "image-name", "name"];
939
988
  for (const [k, v] of Object.entries(opts.props ?? {})) setProp(doc, { kind: "layer", id: l.id }, k, v, void 0, l);
940
989
  g.layers.splice(clampIndex(opts.index ?? 0, g.layers.length), 0, l);
941
990
  return l;
@@ -1022,9 +1071,20 @@ var RULES = {
1022
1071
  "V-06": { severity: "info", title: "Very thin strokes may disappear at small sizes" },
1023
1072
  "V-07": { severity: "info", title: "SVG contains text or custom effects" },
1024
1073
  "V-08": { severity: "warning", title: "Appearance is not customized" },
1025
- "V-09": { severity: "error", title: "More than 4 groups" },
1074
+ "V-09": { severity: "warning", title: "More than 4 groups" },
1026
1075
  "V-10": { severity: "error", title: "Unsupported image format" }
1027
1076
  };
1077
+ function fillHasAlpha(fill) {
1078
+ if (!fill || typeof fill !== "object") return false;
1079
+ const f = fill;
1080
+ const colors = [f.solid, f["automatic-gradient"], ...Array.isArray(f["linear-gradient"]) ? f["linear-gradient"] : []];
1081
+ return colors.some((c) => {
1082
+ const p = typeof c === "string" ? parseColor(c) : null;
1083
+ if (!p) return false;
1084
+ const a = p.space === "gray" || p.space === "extended-gray" ? p.c[1] : p.c[3];
1085
+ return a !== void 0 && a < 1;
1086
+ });
1087
+ }
1028
1088
  function validate(doc) {
1029
1089
  const out = [];
1030
1090
  const push = (id, message, target, ref) => out.push({ id, severity: RULES[id].severity, message, target, ...ref ? { ref } : {} });
@@ -1042,8 +1102,9 @@ function validate(doc) {
1042
1102
  checkProps("document", doc.props, "document", { kind: "document" });
1043
1103
  const fill = resolveProp(doc.props.fill, { appearance: "default" }, "automatic");
1044
1104
  if (fill === "none") push("V-05", 'The background fill is "none". Icons need an opaque, full-bleed background.', "document", { kind: "document" });
1105
+ else if (fillHasAlpha(fill)) push("V-05", "The background color is partly transparent. App Store icons are flattened onto black.", "document", { kind: "document" });
1045
1106
  const visibleGroups = doc.groups.filter((g) => resolveProp(g.props.hidden, { appearance: "default" }, false) !== true).length;
1046
- if (visibleGroups > 4) push("V-09", `This icon has ${visibleGroups} visible groups. Icon Composer supports up to 4, and actool rejects icons with more.`, "document");
1107
+ if (visibleGroups > 4) push("V-09", `This icon has ${visibleGroups} visible groups. Apple recommends at most 4, and Xcode's actool may reject icons with more.`, "document");
1047
1108
  let anyDark = false;
1048
1109
  let anyTinted = false;
1049
1110
  const scan = (props) => {
@@ -1147,7 +1208,27 @@ function summarizeIssues(issues) {
1147
1208
 
1148
1209
  // ../core/src/export.ts
1149
1210
  import { strToU8 as strToU82 } from "fflate";
1211
+ function targetsWatch(doc) {
1212
+ const sp = baseValue(doc.props["supported-platforms"]);
1213
+ return Array.isArray(sp?.circles) && sp.circles.includes("watchOS");
1214
+ }
1215
+ function unrenderableLayers(doc) {
1216
+ const out = [];
1217
+ for (const g of doc.groups) {
1218
+ if (baseValue(g.props.hidden) === true) continue;
1219
+ for (const l of g.layers) {
1220
+ if (baseValue(l.props.hidden) === true) continue;
1221
+ const name = baseValue(l.props["image-name"]);
1222
+ const a = typeof name === "string" ? doc.assets[name] : void 0;
1223
+ if (!a || a.kind === "pdf" || a.kind === "other") out.push(`${baseValue(g.props.name) ?? "Group"}/${baseValue(l.props.name) ?? name ?? "Layer"}`);
1224
+ }
1225
+ }
1226
+ return out;
1227
+ }
1228
+ var MAC_INSET = 100;
1150
1229
  var ICNS_TYPES = [
1230
+ ["icp4", 16],
1231
+ ["icp5", 32],
1151
1232
  ["ic11", 32],
1152
1233
  // 16@2x
1153
1234
  ["ic12", 64],
@@ -1186,15 +1267,15 @@ function encodeIcns(pngBySize) {
1186
1267
  }
1187
1268
  async function buildIcns(rasterize, appearance = "default") {
1188
1269
  const pngs = {};
1189
- for (const s of ICNS_SIZES) pngs[s] = await rasterize(appearance, s);
1270
+ for (const s of ICNS_SIZES) pngs[s] = tagSrgb(await rasterize(appearance, s, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
1190
1271
  return encodeIcns(pngs);
1191
1272
  }
1192
- async function buildAppIconSet(rasterize) {
1273
+ async function buildAppIconSet(rasterize, opts = {}) {
1193
1274
  const files = {};
1194
1275
  const images = [];
1195
- files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none" }));
1196
- files["AppIcon-Dark.png"] = flattenPng(await rasterize("dark", 1024, { mask: "none" }));
1197
- files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", tint: "#FFFFFF" }));
1276
+ files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "iOS" }));
1277
+ files["AppIcon-Dark.png"] = tagSrgb(await rasterize("dark", 1024, { mask: "none", idiom: "iOS", background: false }));
1278
+ files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", idiom: "iOS", tint: "#FFFFFF" }));
1198
1279
  images.push({ filename: "AppIcon.png", idiom: "universal", platform: "ios", size: "1024x1024" });
1199
1280
  images.push({
1200
1281
  appearances: [{ appearance: "luminosity", value: "dark" }],
@@ -1214,19 +1295,124 @@ async function buildAppIconSet(rasterize) {
1214
1295
  for (const scale of [1, 2]) {
1215
1296
  const px = pt * scale;
1216
1297
  const name = `mac-${pt}@${scale}x.png`;
1217
- files[name] = await rasterize("default", px, { mask: "squircle" });
1298
+ files[name] = tagSrgb(await rasterize("default", px, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
1218
1299
  images.push({ filename: name, idiom: "mac", scale: `${scale}x`, size: `${pt}x${pt}` });
1219
1300
  }
1220
1301
  }
1302
+ if (opts.watch) {
1303
+ files["AppIcon-Watch.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "watchOS" }));
1304
+ images.push({ filename: "AppIcon-Watch.png", idiom: "universal", platform: "watchos", size: "1024x1024" });
1305
+ }
1221
1306
  files["Contents.json"] = strToU82(JSON.stringify({ images, info: { author: "fresnel", version: 1 } }, null, 2) + "\n");
1222
1307
  return files;
1223
1308
  }
1224
1309
  async function buildPngSet(rasterize, appearances, sizes) {
1225
1310
  const files = {};
1226
- for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = await rasterize(a, s, { mask: "squircle" });
1311
+ for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = tagSrgb(await rasterize(a, s, { mask: "squircle" }));
1227
1312
  return files;
1228
1313
  }
1229
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
+
1230
1416
  // ../core/src/node.ts
1231
1417
  import { lstat, mkdir, readdir, readFile, rm, stat, writeFile, rename } from "fs/promises";
1232
1418
  import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
@@ -1281,22 +1467,75 @@ async function saveIcon(path, doc) {
1281
1467
  const keep = new Set(Object.keys(doc.assets));
1282
1468
  await assertNoSymlink(root, assetsDir);
1283
1469
  for (const e of await readdir(assetsDir, { withFileTypes: true })) {
1284
- if (e.isFile() && !keep.has(e.name) && e.name !== ".DS_Store") await rm(join(assetsDir, e.name));
1470
+ if (e.isFile() && !keep.has(e.name) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1285
1471
  }
1286
1472
  }
1287
1473
  async function writeFiles(dir, files) {
1288
1474
  const written = [];
1475
+ const root = resolve(dir);
1289
1476
  for (const [rel, bytes] of Object.entries(files)) {
1290
- const target = resolve(dir, ...rel.split("/"));
1291
- const r = relative(resolve(dir), target);
1477
+ const target = resolve(root, ...rel.split("/"));
1478
+ const r = relative(root, target);
1292
1479
  if (r.startsWith("..") || isAbsolute(r)) throw new Error(`Refusing to write outside ${dir}: ${rel}`);
1480
+ await assertNoSymlink(root, target);
1293
1481
  await mkdir(dirname(target), { recursive: true });
1482
+ await assertNoSymlink(root, target);
1294
1483
  await writeFile(target, bytes);
1295
1484
  written.push(target);
1296
1485
  }
1297
1486
  return written;
1298
1487
  }
1299
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
+
1300
1539
  // ../render/src/mask.ts
1301
1540
  function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1302
1541
  const r = size / 2;
@@ -1314,14 +1553,78 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1314
1553
  var SQUIRCLE = squirclePath();
1315
1554
  var C = CANVAS;
1316
1555
  function maskPath(shape) {
1317
- if (shape === "circle") return `M${C / 2},0 A${C / 2},${C / 2} 0 1 1 ${C / 2},${C} A${C / 2},${C / 2} 0 1 1 ${C / 2},0Z`;
1556
+ if (shape === "circle") {
1557
+ const r = C / 2 + 32;
1558
+ return `M${C / 2},${C / 2 - r} A${r},${r} 0 1 1 ${C / 2},${C / 2 + r} A${r},${r} 0 1 1 ${C / 2},${C / 2 - r}Z`;
1559
+ }
1318
1560
  if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
1319
1561
  return SQUIRCLE;
1320
1562
  }
1321
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
+
1322
1615
  // ../render/src/compose.ts
1616
+ function rasterRenderOptions(ro) {
1617
+ if (!ro) return {};
1618
+ return {
1619
+ ...ro.mask ? { mask: ro.mask, rim: ro.mask !== "none" } : {},
1620
+ ...ro.tint ? { tint: ro.tint } : {},
1621
+ ...ro.idiom ? { idiom: ro.idiom } : {},
1622
+ ...ro.background === false ? { background: false } : {},
1623
+ ...ro.inset ? { inset: ro.inset } : {}
1624
+ };
1625
+ }
1323
1626
  var C2 = CANVAS;
1324
- var H = C2 / 2;
1627
+ var H2 = C2 / 2;
1325
1628
  var b64Cache = /* @__PURE__ */ new WeakMap();
1326
1629
  var b64 = (bytes) => {
1327
1630
  const hit = b64Cache.get(bytes);
@@ -1407,7 +1710,7 @@ function renderSvg(doc, opts = {}) {
1407
1710
  const isClear = appearance === "clear-light" || appearance === "clear-dark";
1408
1711
  const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
1409
1712
  const tint = hexToRGBA(opts.tint ?? "#3FA7A0");
1410
- const angle = (opts.lightAngle ?? -45) * Math.PI / 180;
1713
+ const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
1411
1714
  const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
1412
1715
  const defs = new Defs(opts.idPrefix ?? "");
1413
1716
  const body = [];
@@ -1417,7 +1720,8 @@ function renderSvg(doc, opts = {}) {
1417
1720
  let darken = 0;
1418
1721
  if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
1419
1722
  let bgPaint;
1420
- const full = { x: 0, y: 0, w: C2, h: C2 };
1723
+ const P = mask === "circle" ? 32 : 0;
1724
+ const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
1421
1725
  const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" || s.appearance === appearance);
1422
1726
  if (monoOverride) {
1423
1727
  bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
@@ -1436,12 +1740,17 @@ function renderSvg(doc, opts = {}) {
1436
1740
  if (bgFill === void 0) bgFill = "automatic";
1437
1741
  bgPaint = paintFor(bgFill, defs, full, dark);
1438
1742
  }
1743
+ if (opts.background === false) {
1744
+ bgPaint = null;
1745
+ darken = 0;
1746
+ }
1747
+ const backdropLuma = (fillLuma(isClear || isTinted ? dark ? "system-dark" : "system-light" : bgFill, dark) ?? 0.9) * (1 - darken);
1439
1748
  const groups = FRONT_FIRST ? [...doc.groups].reverse() : [...doc.groups];
1440
1749
  let stackId = defs.id("s");
1441
1750
  const bgStackId = stackId;
1442
1751
  const contentIds = [];
1443
1752
  defs.add(
1444
- `<g id="${stackId}">${bgPaint ? `<rect width="${C2}" height="${C2}" fill="${bgPaint}"/>` : ""}` + (darken ? `<rect width="${C2}" height="${C2}" fill="#000" opacity="${darken}"/>` : "") + `</g>`
1753
+ `<g id="${stackId}">${bgPaint ? `<rect x="${-P}" y="${-P}" width="${C2 + 2 * P}" height="${C2 + 2 * P}" fill="${bgPaint}"/>` : ""}` + (darken ? `<rect x="${-P}" y="${-P}" width="${C2 + 2 * P}" height="${C2 + 2 * P}" fill="#000" opacity="${darken}"/>` : "") + `</g>`
1445
1754
  );
1446
1755
  let plainId = bgStackId;
1447
1756
  for (const g of groups) {
@@ -1469,19 +1778,20 @@ function renderSvg(doc, opts = {}) {
1469
1778
  const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
1470
1779
  const w = asset.width * s;
1471
1780
  const h = asset.height * s;
1472
- 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 });
1473
1782
  }
1474
1783
  if (!placed.length) return null;
1475
1784
  const imgId = /* @__PURE__ */ new Map();
1476
1785
  const layerEls = [];
1477
1786
  const glassEls = [];
1787
+ const glassLumas = [];
1478
1788
  for (const p of placed) {
1479
1789
  let iid = imgId.get(p.asset.name);
1480
1790
  if (!iid) {
1481
1791
  iid = defs.id("img");
1482
1792
  imgId.set(p.asset.name, iid);
1483
1793
  defs.add(
1484
- `<image id="${iid}" width="${n(p.asset.width)}" height="${n(p.asset.height)}" preserveAspectRatio="none" href="data:${mimeOf(p.asset)};base64,${b64(p.asset.bytes)}"/>`
1794
+ `<image id="${iid}" width="${n(p.asset.width)}" height="${n(p.asset.height)}" preserveAspectRatio="none" href="data:${mimeOf(p.asset)};base64,${b64(p.asset.display ?? p.asset.bytes)}"/>`
1485
1795
  );
1486
1796
  }
1487
1797
  const s = p.w / p.asset.width;
@@ -1489,7 +1799,9 @@ function renderSvg(doc, opts = {}) {
1489
1799
  const fill = resolveProp(p.layer.props.fill, ctx, null);
1490
1800
  const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
1491
1801
  const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
1492
- 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;
1493
1805
  let el;
1494
1806
  if (paint) {
1495
1807
  const mid = defs.id("lm");
@@ -1500,9 +1812,12 @@ function renderSvg(doc, opts = {}) {
1500
1812
  }
1501
1813
  const wrapped = `<g${opacity < 1 ? ` opacity="${n(opacity)}"` : ""}${blend ? ` style="mix-blend-mode:${blend}"` : ""}>${el}</g>`;
1502
1814
  layerEls.push(wrapped);
1503
- 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
+ }
1504
1819
  }
1505
- 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})"` : "";
1506
1821
  const contentId = defs.id("gc");
1507
1822
  defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
1508
1823
  contentIds.push(contentId);
@@ -1535,13 +1850,27 @@ function renderSvg(doc, opts = {}) {
1535
1850
  defs.add(`<filter id="${fid}" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
1536
1851
  const zoom = 1.02 + (refracts ? (refr.strength ?? 0) * 0.12 : 0.015);
1537
1852
  out.push(
1538
- `<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>`
1854
+ );
1855
+ }
1856
+ const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
1857
+ let fadeMask = "";
1858
+ if (translucent) {
1859
+ const v = Math.min(1, Math.max(0, tr.value));
1860
+ const gid = defs.id("tg");
1861
+ const mid = defs.id("tm");
1862
+ defs.add(
1863
+ `<linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity="${n(1 - v * 0.2)}"/><stop offset="1" stop-color="#fff" stop-opacity="${n(1 - v * 0.85)}"/></linearGradient><mask id="${mid}" maskUnits="objectBoundingBox" maskContentUnits="objectBoundingBox" x="0" y="0" width="1" height="1"><rect width="1" height="1" fill="url(#${gid})"/></mask>`
1539
1864
  );
1865
+ fadeMask = ` mask="url(#${mid})"`;
1540
1866
  }
1541
- const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (translucent ? 1 - tr.value * 0.55 : 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
1542
1867
  const gBlend = cssBlend(resolveProp(g.props["blend-mode"], ctx, "normal"));
1543
- out.push(`<use href="#${contentId}"${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}/>`);
1544
- const specular = resolveProp(g.props.specular, ctx, true);
1868
+ out.push(
1869
+ `<g${fadeMask}${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}><use href="#${contentId}"/></g>`
1870
+ );
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;
1545
1874
  if (effects && specular && glassEls.length) {
1546
1875
  const k = 7;
1547
1876
  const dx = n(-toLight.x * k);
@@ -1587,17 +1916,24 @@ function renderSvg(doc, opts = {}) {
1587
1916
  const clipId = defs.id("clip");
1588
1917
  const shape = maskPath(mask);
1589
1918
  defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
1590
- body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
1919
+ const M = Math.max(0, opts.inset ?? 0);
1920
+ if (M > 0) {
1921
+ const sid = defs.id("ish");
1922
+ defs.add(
1923
+ `<filter id="${sid}" x="-20%" y="-20%" width="140%" height="150%"><feGaussianBlur in="SourceAlpha" stdDeviation="${n(C2 * 0.012)}"/><feOffset dy="${n(C2 * 0.012)}" result="b"/><feFlood flood-color="#000" flood-opacity="0.3"/><feComposite in2="b" operator="in" result="s"/><feMerge><feMergeNode in="s"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
1924
+ );
1925
+ body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
1926
+ } else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
1591
1927
  if (effects && opts.rim !== false && mask !== "none") {
1592
1928
  const rid = defs.id("rim");
1593
- const lx = H + toLight.x * H;
1594
- const ly = H + toLight.y * H;
1929
+ const lx = H2 + toLight.x * H2;
1930
+ const ly = H2 + toLight.y * H2;
1595
1931
  defs.add(
1596
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>`
1597
1933
  );
1598
1934
  body.push(`<g clip-path="url(#${clipId})"><path d="${shape}" fill="none" stroke="url(#${rid})" stroke-width="10"/></g>`);
1599
1935
  }
1600
- return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${size}" height="${size}" viewBox="0 0 ${C2} ${C2}"><title>${esc(appearance)}</title><defs>${defs.toString()}</defs>${body.join("")}</svg>`;
1936
+ return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${size}" height="${size}" viewBox="${-(P + M)} ${-(P + M)} ${C2 + 2 * (P + M)} ${C2 + 2 * (P + M)}"><title>${esc(appearance)}</title><defs>${defs.toString()}</defs>${body.join("")}</svg>`;
1601
1937
  }
1602
1938
  function cssBlend(mode) {
1603
1939
  if (!mode || mode === "normal") return null;
@@ -1610,11 +1946,11 @@ function cssBlend(mode) {
1610
1946
  var SHEET_ORDER = ["default", "clear-light", "tinted-light", "dark", "clear-dark", "tinted-dark"];
1611
1947
  function renderSheetSvg(doc, opts = {}) {
1612
1948
  const cell = opts.cell ?? 320;
1613
- const pad = Math.round(cell * 0.18);
1949
+ const pad2 = Math.round(cell * 0.18);
1614
1950
  const label = opts.labels !== false ? Math.round(cell * 0.12) : 0;
1615
1951
  const w = cell * 3;
1616
1952
  const h = (cell + label) * 2;
1617
- const icon = cell - pad * 2;
1953
+ const icon = cell - pad2 * 2;
1618
1954
  const parts = [];
1619
1955
  SHEET_ORDER.forEach((ap, i) => {
1620
1956
  const x = i % 3 * cell;
@@ -1622,7 +1958,7 @@ function renderSheetSvg(doc, opts = {}) {
1622
1958
  const darkBg = isDarkAppearance(ap);
1623
1959
  parts.push(`<rect x="${x}" y="${y}" width="${cell}" height="${cell + label}" fill="${darkBg ? "#14161F" : "#E3E8F0"}"/>`);
1624
1960
  const svg = renderSvg(doc, { ...opts, appearance: ap, size: icon, idPrefix: `a${i}_` });
1625
- 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}"`));
1626
1962
  if (label)
1627
1963
  parts.push(
1628
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>`
@@ -1647,110 +1983,209 @@ async function svgToPng(svg, width) {
1647
1983
  const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
1648
1984
  return new Uint8Array(r.render().asPng());
1649
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
+ }
1650
2001
  async function renderPng(doc, opts = {}) {
2002
+ await ensureContentBoxes(doc);
1651
2003
  const size = opts.size ?? 1024;
1652
2004
  return svgToPng(renderSvg(doc, { ...opts, size }), size);
1653
2005
  }
1654
2006
  async function renderSheetPng(doc, opts = {}) {
2007
+ await ensureContentBoxes(doc);
1655
2008
  const cell = opts.cell ?? 320;
1656
2009
  return svgToPng(renderSheetSvg(doc, opts), cell * 3);
1657
2010
  }
1658
2011
  function nodeRasterizer(doc, opts = {}) {
1659
- return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...ro?.mask ? { mask: ro.mask, rim: ro.mask !== "none" } : {}, ...ro?.tint ? { tint: ro.tint } : {} });
2012
+ return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...rasterRenderOptions(ro) });
1660
2013
  }
1661
2014
 
1662
- // ../mcp/src/tools.ts
1663
- import { mkdir as mkdir2, readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1664
- import { basename, dirname as dirname2, join as join2 } from "path";
1665
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1666
- import { z as z2 } from "zod";
1667
-
1668
- // ../mcp/src/guide.ts
1669
- var ICON_GUIDE = `# Designing Liquid Glass app icons (iOS, iPadOS, macOS, watchOS)
1670
-
1671
- Source: Apple Human Interface Guidelines, "App icons"
1672
- https://developer.apple.com/design/human-interface-guidelines/app-icons
1673
-
1674
- ## Structure
1675
- - 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.
1676
- - Canvas: 1024\xD71024 for iOS/iPadOS/macOS (masked to a rounded rectangle) and 1088\xD71088 for watchOS (masked to a circle).
1677
- - Appearances: Default, Dark, Clear light, Clear dark, Tinted light, Tinted dark. If you don't provide a variant, the system generates one.
1678
- - Groups: Liquid Glass effects (mode, specular Off/Automatic/Inside/Outside, blur, refraction, translucency, shadow) are configured per group. Use at most 4 groups \u2014 actool rejects icons with more.
1679
- - In Icon Composer the appearances are Default, Dark and Mono (Mono covers clear and tinted); icon.json stores Mono overrides under the "tinted" key.
1680
-
1681
- ## Layers
1682
- - 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.
1683
- - Prefer clearly defined edges. Avoid soft or feathered shapes.
1684
- - Import layers fully opaque and set transparency in the tool. Varying opacity across foreground layers adds depth.
1685
- - Prefer vector art (SVG/PDF). Convert text to outlines. Use PNG for raster art and mesh gradients.
1686
- - Don't bake in highlights, drop shadows, bevels, blurs or glows. The system draws dynamic effects.
1687
-
1688
- ## Design
1689
- - Keep it simple. Use one core idea expressed with a minimal number of filled, overlapping shapes.
1690
- - Prefer a simple background, such as a solid color or gradient. A background image must be full-bleed and opaque.
1691
- - Keep primary content centered so masking (especially circles on watchOS) doesn't truncate it.
1692
- - Avoid text unless it's essential. Avoid photos, UI screenshots and replicas of Apple hardware.
1693
- - Avoid very thin lines and sharp corners; they disappear at small sizes.
1694
-
1695
- ## Appearances
1696
- - Keep the same core features across all appearances. Don't swap elements in and out.
1697
- - Base the dark icon on the light one, using complementary, less bright colors. Colored backgrounds give the most contrast in dark mode.
1698
- - Dark, clear and tinted icons are more subdued. Check that the icon stays legible and recognizable in all of them.
1699
-
1700
- ## fresnel workflow
1701
- 1. icon_create with a background (e.g. "gradient:#3D7BFF,#0E2A8A").
1702
- 2. Draw each foreground element as its own 1024\xD71024 SVG (transparent background, centered artwork) and add it with icon_add_layer.
1703
- 3. Use groups for depth: put elements that should share glass settings in one group. Index 0 is the frontmost.
1704
- 4. Set glass on layers that should catch highlights, then tune the group: translucency, blur-material, specular, shadow.
1705
- 5. Add Dark and Mono ("tinted") overrides with icon_set (appearance: "dark" or "tinted"), and platform variants (platform: "watchOS", \u2026) where layout differs.
1706
- 6. Check icon_render_sheet and icon_validate, then icon_export.
1707
- `;
1708
- function table(title, defs) {
1709
- const rows = Object.entries(defs).map(([k, d]) => `| \`${k}\` | ${d.description} | \`${JSON.stringify(d.default)}\` |`);
1710
- return `### ${title}
1711
-
1712
- | key | meaning | default when absent |
1713
- |---|---|---|
1714
- ${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")}
1715
2089
  `;
1716
2090
  }
1717
- var SCHEMA_GUIDE = `# icon.json attributes understood by fresnel
1718
-
1719
- The format isn't documented by Apple. This reference is reverse-engineered (glassmith, icon-composer-template, real Icon Composer files).
1720
- Any attribute can be overridden per appearance with icon_set(..., appearance: "dark" | "tinted").
1721
- Unknown attributes are accepted and preserved.
1722
-
1723
- ${table("Document", DOCUMENT_ATTRS)}
1724
- ${table("Group", GROUP_ATTRS)}
1725
- ${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
+ }
1726
2171
 
1727
- ### Value formats
1728
- - Color: "#RRGGBB" (stored as srgb) or Icon Composer strings: "display-p3:r,g,b,a", "srgb:r,g,b,a", "extended-gray:l,a"
1729
- - Fill: "automatic" | "system-light" | "system-dark" | "none" | {"solid": color} | {"automatic-gradient": color} | {"linear-gradient": [color, color], "orientation": {"start": {"x","y"}, "stop": {"x","y"}}}
1730
- Shorthands: "#336699" \u2192 solid, "gradient:#top,#bottom" \u2192 linear-gradient, "auto-gradient:#color" \u2192 automatic-gradient
1731
- - Position: {"scale": 1, "translation-in-points": [x, y]} (points on the 1024 canvas, y down, relative to center)
1732
- - Shadow: {"kind": "none" | "neutral" | "layer-color", "opacity": 0\u20131}
1733
- - Translucency: {"enabled": true, "value": 0\u20131}
1734
- - 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
1735
- `;
2172
+ // ../mcp/src/contract.ts
2173
+ import { z as z3 } from "zod";
1736
2174
 
1737
- // ../mcp/src/tools.ts
1738
- var VERSION = "0.1.0";
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;
1739
2181
  var appearanceEnum = z2.enum(APPEARANCES);
1740
2182
  var overrideEnum = z2.enum(["dark", "tinted"]);
2183
+ var platformEnum = z2.enum(["iOS", "macOS", "watchOS", "square"]);
1741
2184
  var maskEnum = z2.enum(["squircle", "circle", "none"]);
1742
- var text = (v) => ({ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) });
1743
- var ok = (...content) => ({ content });
1744
- var fail = (e) => ({ content: [text(`Error: ${e.message}`)], isError: true });
1745
- function safe(fn) {
1746
- return async (args) => {
1747
- try {
1748
- return await fn(args);
1749
- } catch (e) {
1750
- return fail(e);
1751
- }
1752
- };
1753
- }
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()) }));
1754
2189
  function describeProps(props) {
1755
2190
  const base = {};
1756
2191
  const overrides = {};
@@ -1782,6 +2217,7 @@ function inspect(doc) {
1782
2217
  assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
1783
2218
  };
1784
2219
  }
2220
+ var outline = (doc) => inspect(doc).groups.map((g) => ({ group: g.target, layers: g.layers.map((l) => l.target) }));
1785
2221
  function targetLabel(doc, ref) {
1786
2222
  if (ref.kind === "document") return "document";
1787
2223
  if (ref.kind === "group") return nameOf(findGroup(doc, ref.id));
@@ -1791,29 +2227,41 @@ function targetLabel(doc, ref) {
1791
2227
  }
1792
2228
  return "?";
1793
2229
  }
1794
- var b642 = (u) => Buffer.from(u).toString("base64");
1795
- function createServer(ws) {
1796
- const server = new McpServer(
1797
- { name: "fresnel", version: VERSION },
1798
- {
1799
- 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.'
1800
- }
1801
- );
1802
- server.registerTool(
1803
- "icon_create",
1804
- {
1805
- title: "Create icon",
1806
- 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".',
1807
- inputSchema: {
1808
- path: z2.string().describe('Bundle path, e.g. "MyApp.icon" (".icon" is added if missing). Relative paths are inside the first allowed root.'),
1809
- background: z2.string().optional().describe("Background fill for all appearances"),
1810
- dark_background: z2.string().optional().describe("Background override for the dark appearance"),
1811
- watch: z2.boolean().optional().describe("Also target watchOS (circular). Default true"),
1812
- overwrite: z2.boolean().optional().describe("Replace an existing bundle. Default false")
1813
- },
1814
- 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
1815
2256
  },
1816
- 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 }) {
1817
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)");
1818
2266
  const full = await ws.iconPath(path);
1819
2267
  const exists = await stat2(full).then(() => true, () => false);
@@ -1823,63 +2271,101 @@ function createServer(ws) {
1823
2271
  platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
1824
2272
  });
1825
2273
  if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
1826
- await ws.withLock(full, () => saveIcon(full, doc));
1827
- return ok(text({ created: full, ...inspect(doc) }));
1828
- })
1829
- );
1830
- server.registerTool(
1831
- "icon_inspect",
1832
- {
1833
- title: "Inspect icon",
1834
- description: "Shows the groups, layers, attributes and per-appearance overrides of an .icon bundle.",
1835
- inputSchema: { path: z2.string() },
1836
- annotations: { readOnlyHint: true }
1837
- },
1838
- 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 }) {
1839
2296
  const full = await ws.iconPath(path, { mustExist: true });
1840
- return ok(text(inspect(await ws.read(full))));
1841
- })
1842
- );
1843
- server.registerTool(
1844
- "icon_add_group",
1845
- {
1846
- title: "Add group",
1847
- description: "Adds a group. Groups carry the Liquid Glass settings (translucency, blur-material, specular, shadow, lighting).",
1848
- inputSchema: {
1849
- path: z2.string(),
1850
- name: z2.string(),
1851
- index: z2.number().int().min(0).optional().describe("0 = frontmost (default)"),
1852
- props: z2.record(z2.string(), z2.any()).optional().describe('Group attributes, e.g. {"translucency": {"enabled": true, "value": 0.5}, "blur-material": 0.3}')
1853
- }
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
1854
2315
  },
1855
- 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 }) {
1856
2324
  const full = await ws.iconPath(path, { mustExist: true });
1857
- const res = await ws.edit(full, (doc) => {
1858
- const g = addGroup(doc, { name, ...index !== void 0 ? { index } : {} });
1859
- for (const [k, v] of Object.entries(props ?? {})) setProp(doc, { kind: "group", id: g.id }, k, v);
1860
- return { added: nameOf(g), groups: doc.groups.map(nameOf) };
1861
- });
1862
- return ok(text(res));
1863
- })
1864
- );
1865
- server.registerTool(
1866
- "icon_add_layer",
1867
- {
1868
- title: "Add layer",
1869
- 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.",
1870
- inputSchema: {
1871
- path: z2.string(),
1872
- group: z2.string().describe("Group name or #index. A missing group is created in front (index 0); use icon_move to reorder."),
1873
- svg: z2.string().optional().describe("Inline SVG markup"),
1874
- source: z2.string().optional().describe("Image file path"),
1875
- png_base64: z2.string().optional(),
1876
- file_name: z2.string().optional().describe('Name to store in Assets/, e.g. "star.svg"'),
1877
- name: z2.string().optional().describe("Layer name"),
1878
- index: z2.number().int().min(0).optional().describe("Position inside the group, 0 = frontmost (default)"),
1879
- 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]}}')
1880
- }
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
1881
2357
  },
1882
- 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 }) {
1883
2369
  const given = [svg, source, png_base64].filter((x) => x !== void 0).length;
1884
2370
  if (given !== 1) throw new Error("Provide exactly one of svg, source or png_base64");
1885
2371
  let bytes;
@@ -1898,129 +2384,242 @@ function createServer(ws) {
1898
2384
  const asset = makeAsset(fname, bytes);
1899
2385
  if (asset.kind === "other") throw new Error("Unsupported image. Use SVG, PNG or JPEG.");
1900
2386
  const full = await ws.iconPath(path, { mustExist: true });
1901
- const res = await ws.edit(full, (doc) => {
1902
- let gid;
1903
- try {
1904
- const ref = resolvePath(doc, group);
1905
- if (ref.kind !== "group") throw new Error("`group` must name a group");
1906
- gid = ref.id;
1907
- } catch (e) {
1908
- const g = group.trim();
1909
- if (/^#\d+$/.test(g) || g.includes("/") || !/not found/i.test(e.message)) throw e;
1910
- gid = addGroup(doc, { name: g }).id;
1911
- }
1912
- const l = addLayer(doc, gid, asset, {
1913
- ...name ? { name } : {},
1914
- ...index !== void 0 ? { index } : {},
1915
- ...props ? { props } : {}
1916
- });
1917
- return {
1918
- added: targetLabel(doc, { kind: "layer", id: l.id }),
1919
- size: `${asset.width}\xD7${asset.height}`,
1920
- warnings: validate(doc).filter((i) => i.ref?.kind === "layer" && i.ref.id === l.id).map((i) => `${i.id}: ${i.message}`)
1921
- };
1922
- });
1923
- return ok(text(res));
1924
- })
1925
- );
1926
- server.registerTool(
1927
- "icon_set",
1928
- {
1929
- title: "Set attributes",
1930
- 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.',
1931
- inputSchema: {
1932
- path: z2.string(),
1933
- target: z2.string().describe('"document", "<Group>" or "<Group>/<Layer>" (names or #index)'),
1934
- props: z2.record(z2.string(), z2.any()).describe('kebab-case keys, e.g. {"translucency": {"enabled": true, "value": 0.6}, "specular": true}'),
1935
- appearance: overrideEnum.optional(),
1936
- platform: z2.enum(["iOS", "macOS", "watchOS", "square"]).optional()
1937
- }
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
1938
2432
  },
1939
- 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 }) {
1940
2444
  const full = await ws.iconPath(path, { mustExist: true });
1941
- const res = await ws.edit(full, (doc) => {
1942
- const ref = resolvePath(doc, target);
1943
- const variant = appearance || platform ? { ...appearance ? { appearance } : {}, ...platform ? { idiom: platform } : {} } : void 0;
1944
- for (const [k, v] of Object.entries(props)) setProp(doc, ref, k, v, variant);
1945
- return { target: targetLabel(doc, ref), now: describeProps(nodeOf(doc, ref).props) };
1946
- });
1947
- return ok(text(res));
1948
- })
1949
- );
1950
- server.registerTool(
1951
- "icon_remove",
1952
- {
1953
- title: "Remove group or layer",
1954
- description: "Removes a group or a layer. Unused assets are deleted from Assets/.",
1955
- inputSchema: { path: z2.string(), target: z2.string() },
1956
- annotations: { destructiveHint: true }
1957
- },
1958
- 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 }) {
1959
2476
  const full = await ws.iconPath(path, { mustExist: true });
1960
- const res = await ws.edit(full, (doc) => {
1961
- const ref = resolvePath(doc, target);
1962
- const label = targetLabel(doc, ref);
1963
- removeNode(doc, ref);
1964
- return { removed: label, groups: inspect(doc).groups.map((g) => ({ group: g.target, layers: g.layers.map((l) => l.target) })) };
1965
- });
1966
- return ok(text(res));
1967
- })
1968
- );
1969
- server.registerTool(
1970
- "icon_move",
1971
- {
1972
- title: "Reorder",
1973
- description: "Moves a group or layer to a new index (0 = frontmost). Layers can move to another group with to_group.",
1974
- 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
1975
2511
  },
1976
- 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 }) {
1977
2520
  const full = await ws.iconPath(path, { mustExist: true });
1978
- const res = await ws.edit(full, (doc) => {
1979
- const ref = resolvePath(doc, target);
1980
- let gid;
1981
- if (to_group) {
1982
- const g = resolvePath(doc, to_group);
1983
- if (g.kind !== "group") throw new Error("to_group must be a group");
1984
- gid = g.id;
1985
- }
1986
- moveNode(doc, ref, index, gid);
1987
- return inspect(doc).groups.map((g) => ({ group: g.target, layers: g.layers.map((l) => l.target) }));
1988
- });
1989
- return ok(text(res));
1990
- })
1991
- );
1992
- server.registerTool(
1993
- "icon_validate",
1994
- {
1995
- title: "Validate",
1996
- description: "Checks the icon against the schema and Apple HIG recommendations (square unmasked layers, opaque background, no text, variants, \u2026).",
1997
- inputSchema: { path: z2.string() },
1998
- annotations: { readOnlyHint: true }
1999
- },
2000
- 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 }) {
2001
2591
  const full = await ws.iconPath(path, { mustExist: true });
2002
2592
  const issues = validate(await ws.read(full)).map(({ ref: _ref, ...i }) => i);
2003
- return ok(text({ summary: summarizeIssues(issues), issues }));
2004
- })
2005
- );
2006
- server.registerTool(
2007
- "icon_render",
2008
- {
2009
- title: "Render preview",
2010
- description: "Renders an approximate preview PNG of one appearance and returns it as an image. Optionally saves it with `out`.",
2011
- inputSchema: {
2012
- path: z2.string(),
2013
- appearance: appearanceEnum.optional().describe('Default: "default". Mono in Icon Composer = clear-light / clear-dark / tinted-light / tinted-dark'),
2014
- platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
2015
- size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
2016
- mask: maskEnum.optional(),
2017
- tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2018
- light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top, -45 = top-left (default)"),
2019
- out: z2.string().optional().describe("Save the PNG to this path")
2020
- },
2021
- 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")
2022
2614
  },
2023
- 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 }) {
2024
2623
  const full = await ws.iconPath(path, { mustExist: true });
2025
2624
  const doc = await ws.read(full);
2026
2625
  const png = await renderPng(doc, {
@@ -2032,52 +2631,75 @@ function createServer(ws) {
2032
2631
  ...light_angle !== void 0 ? { lightAngle: light_angle } : {}
2033
2632
  });
2034
2633
  const saved = out ? await saveFile(ws, out, png) : void 0;
2035
- return ok({ type: "image", data: b642(png), mimeType: "image/png" }, text({ appearance: appearance ?? "default", ...saved ? { saved } : {} }));
2036
- })
2037
- );
2038
- server.registerTool(
2039
- "icon_render_sheet",
2040
- {
2041
- title: "Render all appearances",
2042
- description: "Renders the six appearances (Default, Clear light, Tinted light / Dark, Clear dark, Tinted dark) side by side and returns the PNG.",
2043
- inputSchema: {
2044
- path: z2.string(),
2045
- cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
2046
- tint: z2.string().optional(),
2047
- out: z2.string().optional()
2048
- },
2049
- 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()
2050
2651
  },
2051
- 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 }) {
2052
2660
  const full = await ws.iconPath(path, { mustExist: true });
2053
2661
  const png = await renderSheetPng(await ws.read(full), { cell: cell ?? 240, ...tint ? { tint } : {} });
2054
2662
  const saved = out ? await saveFile(ws, out, png) : void 0;
2055
- return ok({ type: "image", data: b642(png), mimeType: "image/png" }, ...saved ? [text({ saved })] : []);
2056
- })
2057
- );
2058
- server.registerTool(
2059
- "icon_export",
2060
- {
2061
- title: "Export",
2062
- 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.',
2063
- inputSchema: {
2064
- path: z2.string(),
2065
- out_dir: z2.string(),
2066
- formats: z2.array(z2.enum(["icon", "icns", "appiconset", "png"])).min(1),
2067
- sizes: z2.array(z2.number().int().min(16).max(2048)).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2068
- appearances: z2.array(appearanceEnum).optional().describe("PNG appearances, default all six")
2069
- }
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")
2070
2681
  },
2071
- 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 }) {
2072
2690
  const full = await ws.iconPath(path, { mustExist: true });
2073
2691
  const outDir = await ws.resolve(out_dir);
2074
2692
  const doc = await ws.read(full);
2075
2693
  const name = basename(full).replace(/\.icon(\.zip)?$/, "");
2076
2694
  const raster = nodeRasterizer(doc);
2695
+ const missing = unrenderableLayers(doc);
2696
+ if (missing.length && formats.some((f) => f !== "icon"))
2697
+ throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them first (icon_validate).`);
2077
2698
  const written = [];
2078
2699
  if (formats.includes("icon")) {
2079
- const files = writeBundle(doc, doc.extra);
2080
- written.push(...await writeFiles(join2(outDir, `${name}.icon`), files));
2700
+ const dest = join2(outDir, `${name}.icon`);
2701
+ await saveIcon(dest, doc);
2702
+ written.push(join2(dest, "icon.json"), ...Object.keys(doc.assets).map((a) => join2(dest, "Assets", a)));
2081
2703
  }
2082
2704
  if (formats.includes("icns")) {
2083
2705
  const p = join2(outDir, `${name}.icns`);
@@ -2085,17 +2707,211 @@ function createServer(ws) {
2085
2707
  await writeFile2(p, await buildIcns(raster));
2086
2708
  written.push(p);
2087
2709
  }
2088
- if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster)));
2710
+ if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
2089
2711
  if (formats.includes("png"))
2090
2712
  written.push(
2091
- ...await writeFiles(
2092
- join2(outDir, `${name}-png`),
2093
- await buildPngSet(raster, appearances ?? [...APPEARANCES], sizes ?? [1024, 512, 256, 128, 64, 32, 16])
2094
- )
2713
+ ...await writeFiles(join2(outDir, `${name}-png`), await buildPngSet(raster, appearances ?? [...APPEARANCES], sizes ?? [1024, 512, 256, 128, 64, 32, 16]))
2095
2714
  );
2096
- return ok(text({ out_dir: outDir, files: written.length, written }));
2097
- })
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 }])
2098
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
+ }
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
+ }
2099
2915
  server.registerResource(
2100
2916
  "icon-design-guide",
2101
2917
  "fresnel://guide/icon-design",
@@ -2108,15 +2924,21 @@ function createServer(ws) {
2108
2924
  { title: "icon.json attribute reference", description: "Keys, value formats and defaults fresnel understands", mimeType: "text/markdown" },
2109
2925
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: SCHEMA_GUIDE }] })
2110
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
+ );
2111
2933
  server.registerPrompt(
2112
2934
  "design_app_icon",
2113
2935
  {
2114
2936
  title: "Design an app icon",
2115
2937
  description: "Step-by-step workflow to design a Liquid Glass app icon from an app description",
2116
2938
  argsSchema: {
2117
- app: z2.string().describe("What the app does"),
2118
- path: z2.string().optional().describe("Where to save the .icon bundle"),
2119
- 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")
2120
2942
  }
2121
2943
  },
2122
2944
  ({ app, path, style }) => ({
@@ -2145,12 +2967,6 @@ Use the fresnel tools:
2145
2967
  );
2146
2968
  return server;
2147
2969
  }
2148
- async function saveFile(ws, out, bytes) {
2149
- const p = await ws.resolve(out);
2150
- await mkdir2(dirname2(p), { recursive: true });
2151
- await writeFile2(p, bytes);
2152
- return p;
2153
- }
2154
2970
 
2155
2971
  // ../mcp/src/workspace.ts
2156
2972
  import { realpath, stat as stat3 } from "fs/promises";
@@ -2158,8 +2974,11 @@ import { dirname as dirname3, isAbsolute as isAbsolute2, relative as relative2,
2158
2974
  var Workspace = class {
2159
2975
  roots;
2160
2976
  locks = /* @__PURE__ */ new Map();
2161
- 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 = {}) {
2162
2980
  this.roots = (roots.length ? roots : [process.cwd()]).map((r) => resolve2(r));
2981
+ this.unrestricted = !!opts.unrestricted;
2163
2982
  }
2164
2983
  /** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
2165
2984
  async resolve(p, opts = {}) {
@@ -2185,6 +3004,7 @@ var Workspace = class {
2185
3004
  }
2186
3005
  }
2187
3006
  async allowed(full) {
3007
+ if (this.unrestricted) return true;
2188
3008
  for (const r of this.roots) {
2189
3009
  let root = r;
2190
3010
  try {
@@ -2209,12 +3029,12 @@ var Workspace = class {
2209
3029
  return full;
2210
3030
  }
2211
3031
  /** Serializes edits per file so concurrent tool calls can't clobber each other. */
2212
- async edit(path, fn) {
3032
+ async edit(path, fn, opts = {}) {
2213
3033
  return this.withLock(path, async () => {
2214
3034
  if (path.endsWith(".zip")) throw new Error("Editing .icon.zip is not supported; unzip it first or use the .icon folder");
2215
3035
  const doc = await loadIcon(path);
2216
3036
  const result = await fn(doc);
2217
- await saveIcon(path, doc);
3037
+ if (!opts.dryRun) await saveIcon(path, doc);
2218
3038
  return result;
2219
3039
  });
2220
3040
  }
@@ -2234,7 +3054,8 @@ var Workspace = class {
2234
3054
  };
2235
3055
 
2236
3056
  // src/cli.ts
2237
- 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})
2238
3059
 
2239
3060
  Usage
2240
3061
  fresnel build <spec.json> -o <Name.icon> Build an .icon bundle from a JSON spec
@@ -2245,14 +3066,42 @@ Usage
2245
3066
  Render an approximate preview PNG
2246
3067
  fresnel export <Name.icon> -o <dir> [--formats icon,icns,appiconset,png]
2247
3068
  [--sizes 1024,512,...] [--appearances default,dark,...]
2248
- [--tint #RRGGBB]
3069
+ [--tint #RRGGBB] [--force]
2249
3070
  fresnel mcp [--root <dir>]... Start the MCP server on stdio
2250
3071
 
2251
3072
  Render options: --mask squircle|circle|none, --tint #RRGGBB (tinted appearances)
2252
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
+
2253
3080
  Appearances: ${APPEARANCES.join(", ")}
2254
3081
  Docs: https://github.com/kajisho5/fresnel
2255
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
+ }
2256
3105
  async function build(specPath, out) {
2257
3106
  const spec = JSON.parse(await readFile3(specPath, "utf8"));
2258
3107
  const base = dirname4(resolve3(specPath));
@@ -2311,6 +3160,30 @@ async function main() {
2311
3160
  console.log(VERSION);
2312
3161
  return;
2313
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
+ }
2314
3187
  const { values, positionals } = parseArgs({
2315
3188
  args: rest,
2316
3189
  allowPositionals: true,
@@ -2327,6 +3200,7 @@ async function main() {
2327
3200
  tint: { type: "string" },
2328
3201
  mask: { type: "string" },
2329
3202
  root: { type: "string", short: "r", multiple: true },
3203
+ force: { type: "boolean" },
2330
3204
  help: { type: "boolean", short: "h" }
2331
3205
  }
2332
3206
  });
@@ -2381,14 +3255,19 @@ async function main() {
2381
3255
  const formats = (values.formats ?? "icon,icns,appiconset,png").split(",").map((s) => s.trim());
2382
3256
  const raster = nodeRasterizer(doc, values.tint ? { tint: values.tint } : {});
2383
3257
  const outDir = resolve3(values.out);
3258
+ const missing = unrenderableLayers(doc);
3259
+ if (missing.length && formats.some((f) => f !== "icon") && !values.force)
3260
+ throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them, or pass --force.`);
2384
3261
  let n2 = 0;
2385
3262
  for (const f of formats) {
2386
- if (f === "icon") n2 += (await writeFiles(join3(outDir, `${name}.icon`), writeBundle(doc, doc.extra))).length;
2387
- else if (f === "icns") {
3263
+ if (f === "icon") {
3264
+ await saveIcon(join3(outDir, `${name}.icon`), doc);
3265
+ n2 += 1 + Object.keys(doc.assets).length;
3266
+ } else if (f === "icns") {
2388
3267
  await mkdir3(outDir, { recursive: true });
2389
3268
  await writeFile3(join3(outDir, `${name}.icns`), await buildIcns(raster));
2390
3269
  n2++;
2391
- } else if (f === "appiconset") n2 += (await writeFiles(join3(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster))).length;
3270
+ } else if (f === "appiconset") n2 += (await writeFiles(join3(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) }))).length;
2392
3271
  else if (f === "png") {
2393
3272
  const sizes = (values.sizes ?? "1024,512,256,128,64,32,16").split(",").map(Number);
2394
3273
  n2 += (await writeFiles(join3(outDir, `${name}-png`), await buildPngSet(raster, appearances(values.appearances), sizes))).length;