fresnel-mcp 0.2.0 → 0.2.1

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 (2) hide show
  1. package/dist/server.js +315 -127
  2. package/package.json +3 -3
package/dist/server.js CHANGED
@@ -23,8 +23,12 @@ var APPEARANCE_LABEL = {
23
23
  function appearanceKeys(a) {
24
24
  switch (a) {
25
25
  case "dark":
26
- case "clear-dark":
27
26
  return ["dark"];
27
+ // Clear and Tinted (light and dark) are all rendered from Icon Composer's Mono, written as "tinted".
28
+ // Apple: "For mono, you can preview clear and tinted variants"; Assets.car keeps only light / dark /
29
+ // tintable renditions. A dark value does not carry over to Clear dark [推測: strong].
30
+ case "clear-light":
31
+ case "clear-dark":
28
32
  case "tinted-light":
29
33
  case "tinted-dark":
30
34
  return ["tinted"];
@@ -140,6 +144,7 @@ function readProps(obj) {
140
144
  function writeProps(props, order, structural = {}) {
141
145
  const out = {};
142
146
  const emitted = /* @__PURE__ */ new Set();
147
+ let fromFile = true;
143
148
  const emit = (rawKey) => {
144
149
  if (emitted.has(rawKey)) return;
145
150
  if (rawKey in structural) {
@@ -147,17 +152,28 @@ function writeProps(props, order, structural = {}) {
147
152
  emitted.add(rawKey);
148
153
  return;
149
154
  }
155
+ const literal = props[rawKey];
156
+ if (rawKey.endsWith(SPEC_SUFFIX) && literal?.plain) {
157
+ out[rawKey] = structuredClone(literal.plain.value);
158
+ emitted.add(rawKey);
159
+ return;
160
+ }
150
161
  const isSpec = rawKey.endsWith(SPEC_SUFFIX);
151
162
  const base = isSpec ? rawKey.slice(0, -SPEC_SUFFIX.length) : rawKey;
152
163
  const p = props[base];
153
164
  if (!p) return;
154
- if (isSpec ? p.specs && p.specs.length > 0 : p.plain) {
165
+ if (isSpec ? p.specs && (p.specs.length > 0 || fromFile) : p.plain) {
155
166
  out[rawKey] = structuredClone(isSpec ? p.specs : p.plain.value);
156
167
  emitted.add(rawKey);
157
168
  }
158
169
  };
159
170
  for (const k of order) emit(k);
171
+ fromFile = false;
160
172
  for (const [base, p] of Object.entries(props)) {
173
+ if (base.endsWith(SPEC_SUFFIX) && p.plain) {
174
+ emit(base);
175
+ continue;
176
+ }
161
177
  if (p.plain) emit(base);
162
178
  if (p.specs?.length) emit(base + SPEC_SUFFIX);
163
179
  }
@@ -205,6 +221,11 @@ function baseValue(p) {
205
221
  if (b) return b.value;
206
222
  return p.plain?.value;
207
223
  }
224
+ function variantValue(p, v) {
225
+ const vv = toVariant(v);
226
+ if (isEmpty(vv)) return baseValue(p);
227
+ return p?.specs?.find((e) => sameVariant(e, vv))?.value;
228
+ }
208
229
  function variantsOf(p) {
209
230
  return (p?.specs ?? []).filter((e) => !isBaseEntry(e)).map((e) => ({ ...e.appearance ? { appearance: e.appearance } : {}, ...e.idiom ? { idiom: e.idiom } : {} }));
210
231
  }
@@ -397,6 +418,7 @@ function svgSize(svg) {
397
418
  return { width: 1024, height: 1024 };
398
419
  }
399
420
  function pngSize(b) {
421
+ if (b.length < 24) return { width: 0, height: 0 };
400
422
  const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
401
423
  return { width: dv.getUint32(16), height: dv.getUint32(20) };
402
424
  }
@@ -408,6 +430,10 @@ function jpgSize(b) {
408
430
  continue;
409
431
  }
410
432
  const marker = b[i + 1];
433
+ if (marker === 255) {
434
+ i++;
435
+ continue;
436
+ }
411
437
  const len = b[i + 2] << 8 | b[i + 3];
412
438
  if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
413
439
  return { height: b[i + 5] << 8 | b[i + 6], width: b[i + 7] << 8 | b[i + 8] };
@@ -447,15 +473,26 @@ function expandInternalEntities(svg) {
447
473
  if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
448
474
  }
449
475
  const body = svg.slice(0, doctype.index) + svg.slice(doctype.index + doctype[0].length);
450
- return body.replace(/&([\w.-]+);/g, (m, name) => ents.get(name) ?? m);
476
+ let budget = Math.max(1 << 20, body.length * 4);
477
+ return body.replace(/&([\w.-]+);/g, (m, name) => {
478
+ const v = ents.get(name);
479
+ if (v === void 0 || v.length > budget) return m;
480
+ budget -= v.length;
481
+ return v;
482
+ });
451
483
  }
484
+ var DATA_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp|svg\+xml)(?:;[\w.+-]+=[\w.+-]+)*;base64,[a-z0-9+/=\s]*$/i;
452
485
  function safeUrlRef(v) {
453
486
  const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
454
- return t.startsWith("#") || /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]*$/i.test(t);
487
+ return t.startsWith("#") || DATA_IMAGE.test(t);
488
+ }
489
+ function decodeXmlEntities(v) {
490
+ 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(/&(lt|gt|quot|apos|amp);/g, (_m, n2) => ({ lt: "<", gt: ">", quot: '"', apos: "'", amp: "&" })[n2]);
455
491
  }
492
+ var escapeXmlText = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
456
493
  function cleanCss(css0) {
457
494
  const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
458
- return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
495
+ return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/(?:-webkit-)?image-set\s*\(|\bsrc\s*\(/gi, "(").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
459
496
  }
460
497
  function cleanAttrs(raw) {
461
498
  const out = [];
@@ -476,8 +513,10 @@ function cleanAttrs(raw) {
476
513
  if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
477
514
  let v = value;
478
515
  if (lname === "style" || /url\(/.test(plain)) {
479
- v = cleanCss(v);
480
- if (/url\((?!['"]?#)/.test(decodeForCheck(v).replace(/url\(['"]?data:image\/(png|jpe?g|gif|webp);base64,/g, "url(#"))) continue;
516
+ const css = cleanCss(decodeXmlEntities(v));
517
+ const refs = [...decodeForCheck(css).matchAll(/url\(([^)]*)\)/g)].map((m2) => m2[1]);
518
+ if (refs.some((r) => !safeUrlRef(r))) continue;
519
+ v = escapeXmlText(css);
481
520
  }
482
521
  out.push(`${name}="${v.replace(/"/g, "&quot;")}"`);
483
522
  }
@@ -491,7 +530,7 @@ function sanitizeOnce(svg) {
491
530
  while (i < svg.length) {
492
531
  const lt = svg.indexOf("<", i);
493
532
  const text2 = lt < 0 ? svg.slice(i) : svg.slice(i, lt);
494
- if (!skip.length) out += inStyle ? cleanCss(text2) : text2;
533
+ if (!skip.length) out += inStyle ? escapeXmlText(cleanCss(decodeXmlEntities(text2))) : text2;
495
534
  if (lt < 0) break;
496
535
  if (svg.startsWith("<!--", lt)) {
497
536
  const e = svg.indexOf("-->", lt + 4);
@@ -501,7 +540,7 @@ function sanitizeOnce(svg) {
501
540
  if (svg.startsWith("<![CDATA[", lt)) {
502
541
  const e = svg.indexOf("]]>", lt);
503
542
  const body = svg.slice(lt + 9, e < 0 ? svg.length : e);
504
- if (!skip.length) out += inStyle ? `<![CDATA[${cleanCss(body)}]]>` : `<![CDATA[${body}]]>`;
543
+ if (!skip.length) out += inStyle ? escapeXmlText(cleanCss(body)) : `<![CDATA[${body}]]>`;
505
544
  i = e < 0 ? svg.length : e + 3;
506
545
  continue;
507
546
  }
@@ -574,12 +613,12 @@ function sanitizeOnce(svg) {
574
613
  }
575
614
  function sanitizeSvg(svg) {
576
615
  let s = expandInternalEntities(svg);
577
- for (let n2 = 0; n2 < 5; n2++) {
616
+ for (let n2 = 0; n2 < 8; n2++) {
578
617
  const next = sanitizeOnce(s);
579
- if (next === s) break;
618
+ if (next === s) return s;
580
619
  s = next;
581
620
  }
582
- return s;
621
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"/>';
583
622
  }
584
623
  function svgFacts(svg) {
585
624
  const widths = [...svg.matchAll(/stroke-width\s*[:=]\s*["']?\s*([0-9.]+)/gi)].map((m) => Number(m[1])).filter((n2) => n2 > 0);
@@ -606,7 +645,15 @@ function assetText(a) {
606
645
  function mimeOf(a) {
607
646
  return a.kind === "svg" ? "image/svg+xml" : a.kind === "png" ? "image/png" : a.kind === "jpg" ? "image/jpeg" : "application/octet-stream";
608
647
  }
648
+ var MAX_DECODE_PIXELS = 64e6;
609
649
  function decodePng(b) {
650
+ try {
651
+ return decodePngUnchecked(b);
652
+ } catch {
653
+ return null;
654
+ }
655
+ }
656
+ function decodePngUnchecked(b) {
610
657
  const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
611
658
  let off = 8;
612
659
  let width = 0;
@@ -634,6 +681,7 @@ function decodePng(b) {
634
681
  off += 12 + len;
635
682
  }
636
683
  if (depth !== 8 || interlace !== 0) return null;
684
+ if (!width || !height || width * height > MAX_DECODE_PIXELS) return null;
637
685
  const ch = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ctype];
638
686
  if (!ch) return null;
639
687
  const total = idat.reduce((a, x) => a + x.length, 0);
@@ -811,7 +859,7 @@ function isSafeBundlePath(rel) {
811
859
  return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
812
860
  }
813
861
  function readBundle(files) {
814
- const jsonKey = Object.keys(files).find((k) => k === "icon.json" || k.endsWith("/icon.json"));
862
+ const jsonKey = Object.keys(files).filter((k) => k === "icon.json" || k.endsWith("/icon.json")).sort((a, b) => a.split("/").length - b.split("/").length || a.length - b.length)[0];
815
863
  if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
816
864
  const prefix = jsonKey.slice(0, -"icon.json".length);
817
865
  const base = parseIconJson(strFromU8(files[jsonKey]));
@@ -820,7 +868,7 @@ function readBundle(files) {
820
868
  for (const [k, v] of Object.entries(files)) {
821
869
  if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
822
870
  const rel = k.slice(prefix.length);
823
- if (!isSafeBundlePath(rel)) continue;
871
+ if (!isSafeBundlePath(rel) || rel.endsWith(".fresnel-tmp")) continue;
824
872
  if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
825
873
  const name = rel.slice("Assets/".length);
826
874
  if (name) assets[name] = makeAsset(name, v);
@@ -855,10 +903,12 @@ function syncFeatures(doc) {
855
903
  }
856
904
  function writeBundle(doc, extra = {}) {
857
905
  const synced = { props: { ...doc.props }, groups: doc.groups };
906
+ if (synced.props.features) synced.props.features = structuredClone(synced.props.features);
858
907
  syncFeatures(synced);
859
908
  const files = { ...extra };
860
- files["icon.json"] = strToU8(serializeIconJson({ ...doc, props: synced.props }));
861
909
  for (const a of Object.values(doc.assets)) files[`Assets/${a.name}`] = a.bytes;
910
+ delete files["icon.json"];
911
+ files["icon.json"] = strToU8(serializeIconJson({ ...doc, props: synced.props }));
862
912
  return files;
863
913
  }
864
914
  function unzipBundle(zip) {
@@ -907,8 +957,13 @@ function resolvePath(doc, path) {
907
957
  const p = path.trim();
908
958
  if (p === "" || p === "document" || p === "/") return { kind: "document" };
909
959
  const whole = doc.groups.filter((g2) => nameOf(g2).toLowerCase() === p.toLowerCase());
910
- if (whole.length === 1) return { kind: "group", id: whole[0].id };
911
960
  const [gp, lp, ...rest] = p.split("/");
961
+ if (whole.length === 1) {
962
+ const g2 = lp !== void 0 && !rest.length ? doc.groups.find((x) => nameOf(x).toLowerCase() === gp.trim().toLowerCase()) : void 0;
963
+ if (g2?.layers.some((l2) => nameOf(l2).toLowerCase() === lp.trim().toLowerCase()))
964
+ throw new OpError(`"${path}" matches both a group and a layer; use #index (e.g. "#${doc.groups.indexOf(whole[0])}")`);
965
+ return { kind: "group", id: whole[0].id };
966
+ }
912
967
  if (rest.length) throw new OpError(`Invalid target "${path}". Use "Group" or "Group/Layer".`);
913
968
  const pick = (list, key, what) => {
914
969
  const k = key.trim();
@@ -945,9 +1000,10 @@ function cleanName(name) {
945
1000
  }
946
1001
  function uniqueName(existing, wanted0) {
947
1002
  const wanted = cleanName(wanted0) || "Untitled";
948
- if (!existing.includes(wanted)) return wanted;
1003
+ const taken = new Set(existing.map((n2) => n2.toLowerCase()));
1004
+ if (!taken.has(wanted.toLowerCase())) return wanted;
949
1005
  let i = 2;
950
- while (existing.includes(`${wanted} ${i}`)) i++;
1006
+ while (taken.has(`${wanted} ${i}`.toLowerCase())) i++;
951
1007
  return `${wanted} ${i}`;
952
1008
  }
953
1009
  function addGroup(doc, opts = {}) {
@@ -968,10 +1024,15 @@ function clampIndex(i, len) {
968
1024
  function addAsset(doc, asset) {
969
1025
  let name = asset.name.replace(/[\\/\u0000-\u001f:]/g, "_").replace(/^[.\s]+/, "").trim() || "image";
970
1026
  const dot = name.lastIndexOf(".");
971
- const stem = dot > 0 ? name.slice(0, dot) : name;
972
- const ext = dot > 0 ? name.slice(dot) : "";
1027
+ const ext = dot > 0 ? name.slice(dot, dot + 16) : "";
1028
+ let stem = dot > 0 ? name.slice(0, dot) : name;
1029
+ while (new TextEncoder().encode(stem).length > 200) stem = stem.slice(0, -1);
1030
+ name = stem + ext;
1031
+ const clash = (n2) => Object.values(doc.assets).find((a) => a.name.toLowerCase() === n2.toLowerCase());
973
1032
  let i = 2;
974
- while (doc.assets[name] && !sameBytes(doc.assets[name].bytes, asset.bytes)) name = `${stem} ${i++}${ext}`;
1033
+ for (let c = clash(name); c && !sameBytes(c.bytes, asset.bytes); c = clash(name)) name = `${stem} ${i++}${ext}`;
1034
+ const same = clash(name);
1035
+ if (same) return same.name;
975
1036
  doc.assets[name] = { ...asset, name };
976
1037
  return name;
977
1038
  }
@@ -1023,31 +1084,40 @@ function normalizeFillColors(f) {
1023
1084
  const o = { ...f };
1024
1085
  if (typeof o.solid === "string") o.solid = normalizeColorInput(o.solid);
1025
1086
  if (typeof o["automatic-gradient"] === "string") o["automatic-gradient"] = normalizeColorInput(o["automatic-gradient"]);
1026
- if (Array.isArray(o["linear-gradient"])) o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
1087
+ if (Array.isArray(o["linear-gradient"])) {
1088
+ if (!o["linear-gradient"].every((c) => typeof c === "string")) throw new OpError("linear-gradient takes a list of color strings");
1089
+ o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
1090
+ }
1027
1091
  return o;
1028
1092
  }
1029
1093
  function removeNode(doc, ref) {
1030
1094
  if (ref.kind === "document") throw new OpError("The document can't be removed");
1095
+ let removedImages;
1031
1096
  if (ref.kind === "group") {
1032
1097
  const i = doc.groups.findIndex((g) => g.id === ref.id);
1033
1098
  if (i < 0) throw new OpError("Group not found");
1099
+ removedImages = imagesOf(doc.groups[i].layers);
1034
1100
  doc.groups.splice(i, 1);
1035
1101
  } else {
1036
1102
  const f = findLayer(doc, ref.id);
1037
1103
  if (!f) throw new OpError("Layer not found");
1104
+ removedImages = imagesOf([f.layer]);
1038
1105
  f.group.layers.splice(f.index, 1);
1039
1106
  }
1040
- pruneAssets(doc);
1107
+ pruneAssets(doc, removedImages);
1041
1108
  }
1042
- function pruneAssets(doc) {
1109
+ function imagesOf(layers) {
1043
1110
  const used = /* @__PURE__ */ new Set();
1044
- for (const g of doc.groups)
1045
- for (const l of g.layers) {
1046
- const p = l.props["image-name"];
1047
- if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
1048
- for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
1049
- }
1050
- for (const k of Object.keys(doc.assets)) if (!used.has(k)) delete doc.assets[k];
1111
+ for (const l of layers) {
1112
+ const p = l.props["image-name"];
1113
+ if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
1114
+ for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
1115
+ }
1116
+ return used;
1117
+ }
1118
+ function pruneAssets(doc, only) {
1119
+ const used = imagesOf(doc.groups.flatMap((g) => g.layers));
1120
+ for (const k of Object.keys(doc.assets)) if (!used.has(k) && (!only || only.has(k))) delete doc.assets[k];
1051
1121
  }
1052
1122
  function moveNode(doc, ref, index, toGroupId) {
1053
1123
  if (ref.kind === "document") throw new OpError("The document can't be moved");
@@ -1114,9 +1184,10 @@ function validate(doc) {
1114
1184
  let anyTinted = false;
1115
1185
  const scan = (props) => {
1116
1186
  for (const p of Object.values(props)) {
1117
- const o = overriddenAppearances(p);
1118
- if (o.includes("dark")) anyDark = true;
1119
- if (o.includes("tinted")) anyTinted = true;
1187
+ for (const v of variantsOf(p)) {
1188
+ if (v.appearance === "dark") anyDark = true;
1189
+ if (v.appearance === "tinted") anyTinted = true;
1190
+ }
1120
1191
  }
1121
1192
  };
1122
1193
  scan(doc.props);
@@ -1147,19 +1218,22 @@ function validate(doc) {
1147
1218
  }
1148
1219
  if (Math.abs(a.width - a.height) > 0.5) {
1149
1220
  push("V-03", `"${n2}" is ${round(a.width)}\xD7${round(a.height)}. Apple recommends square, unmasked layers.`, lName, lref);
1150
- } else if (a.kind !== "svg" && a.width * pos.scale < 1024) {
1151
- push("V-03", `"${n2}" is ${a.width}\xD7${a.height} px and may look soft at 1024. Use 1024\xD71024 or a vector.`, lName, lref);
1221
+ } else if (a.kind !== "svg" && pos.scale > 1.01) {
1222
+ push("V-03", `"${n2}" (${a.width}\xD7${a.height} px) is scaled up ${round(pos.scale)}\xD7 and may look soft. Use a larger image or a vector.`, lName, lref);
1152
1223
  }
1153
1224
  if (a.kind === "png" && a.width === a.height && a.width >= 256) {
1154
1225
  const bmp = safeDecode(a.bytes);
1155
1226
  if (bmp && looksPreMasked(bmp)) push("V-04", `"${n2}" seems to have rounded corners baked in. The system masks icons itself.`, lName, lref);
1156
1227
  }
1157
1228
  if (a.kind === "svg") {
1158
- const f = svgFacts(assetText(a));
1229
+ const text2 = assetText(a);
1230
+ const f = svgFacts(text2);
1231
+ const vb = /<svg\b[^>]*\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)/i.exec(text2);
1232
+ const unit2 = vb && Number(vb[1]) > 0 ? svgSize(text2).width / Number(vb[1]) : 1;
1159
1233
  if (f.hasText) push("V-07", `"${n2}" contains <text>. Convert text to outlines.`, lName, lref);
1160
1234
  if (f.hasFilter) push("V-07", `"${n2}" uses SVG filters (blur, glow, shadow). Let the system draw effects.`, lName, lref);
1161
- if (f.minStrokeWidth !== null && f.minStrokeWidth * pos.scale < 10)
1162
- push("V-06", `"${n2}" has strokes as thin as ${f.minStrokeWidth}. They'll be under 0.16 px at 16 px.`, lName, lref);
1235
+ if (f.minStrokeWidth !== null && f.minStrokeWidth * unit2 * pos.scale < 10)
1236
+ push("V-06", `"${n2}" has strokes as thin as ${round(f.minStrokeWidth * unit2)} pt. They'll be under 0.16 px at 16 px.`, lName, lref);
1163
1237
  }
1164
1238
  }
1165
1239
  }
@@ -1375,10 +1449,24 @@ function moveBy(doc, ref, dx, dy, ctx, variant) {
1375
1449
  props = f.layer.props;
1376
1450
  scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
1377
1451
  }
1378
- const p = resolveProp(props.position, ctx, { scale: 1, "translation-in-points": [0, 0] }) ?? { scale: 1, "translation-in-points": [0, 0] };
1379
- const [x, y] = p["translation-in-points"] ?? [0, 0];
1380
1452
  const round2 = (v) => Math.round(v * 100) / 100;
1381
- setProp(doc, ref, "position", { ...p, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] }, variant);
1453
+ const shift = (p) => {
1454
+ const q = p ?? { scale: 1, "translation-in-points": [0, 0] };
1455
+ const [x, y] = q["translation-in-points"] ?? [0, 0];
1456
+ return { ...q, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] };
1457
+ };
1458
+ const vv = toVariant(variant);
1459
+ if (vv.appearance !== void 0 || vv.idiom !== void 0) {
1460
+ const cur = variantValue(props.position, vv) ?? resolveProp(props.position, ctx, null);
1461
+ setProp(doc, ref, "position", shift(cur), variant);
1462
+ return;
1463
+ }
1464
+ const pos = props.position;
1465
+ setProp(doc, ref, "position", shift(baseValue(pos)), void 0);
1466
+ for (const e of pos?.specs ?? []) {
1467
+ if (e.appearance === void 0 && e.idiom === void 0) continue;
1468
+ if (e.value && typeof e.value === "object") e.value = shift(e.value);
1469
+ }
1382
1470
  }
1383
1471
  function alignNodes(doc, refs, how, ctx, opts = {}) {
1384
1472
  const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
@@ -1495,8 +1583,8 @@ function exampleCommand(t, args) {
1495
1583
  }
1496
1584
 
1497
1585
  // src/spec.ts
1498
- import { mkdir as mkdir2, readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1499
- import { basename, dirname as dirname2, join as join2 } from "path";
1586
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
1587
+ import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, sep as sep2 } from "path";
1500
1588
  import { z as z2 } from "zod";
1501
1589
 
1502
1590
  // ../core/src/node.ts
@@ -1540,6 +1628,7 @@ async function saveIcon(path, doc) {
1540
1628
  const assetsDir = join(root, "Assets");
1541
1629
  await assertNoSymlink(root, assetsDir);
1542
1630
  await mkdir(assetsDir, { recursive: true });
1631
+ const written = [];
1543
1632
  for (const [rel, bytes] of Object.entries(files)) {
1544
1633
  const target = resolve(root, ...rel.split("/"));
1545
1634
  await assertNoSymlink(root, target);
@@ -1549,16 +1638,20 @@ async function saveIcon(path, doc) {
1549
1638
  await rm(tmp, { force: true });
1550
1639
  await writeFile(tmp, bytes, { flag: "wx" });
1551
1640
  await rename(tmp, target);
1641
+ written.push(target);
1552
1642
  }
1553
1643
  const keep = new Set(Object.keys(doc.assets));
1554
1644
  await assertNoSymlink(root, assetsDir);
1555
1645
  for (const e of await readdir(assetsDir, { withFileTypes: true })) {
1556
- if (e.isFile() && !keep.has(e.name) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1646
+ if (!e.isFile() || keep.has(e.name)) continue;
1647
+ if (e.name.endsWith(".fresnel-tmp") || isSafeBundlePath(`Assets/${e.name}`) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1557
1648
  }
1649
+ return written;
1558
1650
  }
1559
1651
  async function writeFiles(dir, files) {
1560
1652
  const written = [];
1561
1653
  const root = resolve(dir);
1654
+ if ((await lstat(root).catch(() => null))?.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${dir}`);
1562
1655
  for (const [rel, bytes] of Object.entries(files)) {
1563
1656
  const target = resolve(root, ...rel.split("/"));
1564
1657
  const r = relative(root, target);
@@ -1609,7 +1702,7 @@ function contentBoxOf(asset) {
1609
1702
  }
1610
1703
  function svgAssetsNeedingBounds(doc) {
1611
1704
  const out = /* @__PURE__ */ new Set();
1612
- const hasGradient = (p) => [p?.plain?.value, ...(p?.specs ?? []).map((s) => s.value)].some((v) => !!v && typeof v === "object" && "linear-gradient" in v);
1705
+ const hasGradient = (p) => [p?.plain?.value, ...(p?.specs ?? []).map((s) => s.value)].some((v) => !!v && typeof v === "object" && ("linear-gradient" in v || "automatic-gradient" in v));
1613
1706
  for (const g of doc.groups)
1614
1707
  for (const l of g.layers) {
1615
1708
  if (!hasGradient(l.props.fill)) continue;
@@ -1638,12 +1731,12 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1638
1731
  }
1639
1732
  var SQUIRCLE = squirclePath();
1640
1733
  var C = CANVAS;
1641
- function maskPath(shape) {
1734
+ function maskPath(shape, pad = 0) {
1642
1735
  if (shape === "circle") {
1643
1736
  const r = C / 2 + 32;
1644
1737
  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`;
1645
1738
  }
1646
- if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
1739
+ if (shape === "none") return `M${-pad},${-pad} H${C + pad} V${C + pad} H${-pad}Z`;
1647
1740
  return SQUIRCLE;
1648
1741
  }
1649
1742
 
@@ -1669,6 +1762,17 @@ function hexLuma(hex) {
1669
1762
  const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
1670
1763
  return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
1671
1764
  }
1765
+ var NAMED = { white: 1, black: 0 };
1766
+ function paintLuma(v) {
1767
+ if (v.startsWith("#")) return hexLuma(v);
1768
+ const m = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/i.exec(v);
1769
+ if (m) {
1770
+ const ch = (x) => Math.min(1, Math.max(0, x.endsWith("%") ? Number(x.slice(0, -1)) / 100 : Number(x) / 255));
1771
+ return luminance({ r: ch(m[1]), g: ch(m[2]), b: ch(m[3]), a: 1 });
1772
+ }
1773
+ const named = NAMED[v.toLowerCase()];
1774
+ return named ?? null;
1775
+ }
1672
1776
  function assetLuma(a) {
1673
1777
  const hit = cache2.get(a.bytes);
1674
1778
  if (hit !== void 0) return hit;
@@ -1689,10 +1793,11 @@ function assetLuma(a) {
1689
1793
  }
1690
1794
  } else if (a.kind === "svg") {
1691
1795
  const text2 = new TextDecoder().decode(a.display ?? a.bytes);
1692
- 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);
1693
- const named = [...text2.matchAll(/(?:fill|stop-color)\s*[:=]\s*["']?\s*(white|black)\b/gi)].map((m) => m[1].toLowerCase() === "white" ? 1 : 0);
1694
- const all = [...found, ...named];
1695
- if (all.length) l = all.reduce((x, y) => x + y, 0) / all.length;
1796
+ const paint = /(?:fill|stop-color)\s*[:=]\s*["']?\s*(rgba?\([^)]*\)|[^"';>\s]+)/gi;
1797
+ const values = [...text2.matchAll(paint)].map((m) => m[1].trim()).filter((v) => !/^(none|transparent|inherit|currentcolor|url\()/i.test(v));
1798
+ const ls = values.map(paintLuma).filter((x) => x !== null);
1799
+ if (ls.length) l = ls.reduce((x, y) => x + y, 0) / ls.length;
1800
+ else l = values.length ? 0.5 : 0;
1696
1801
  }
1697
1802
  cache2.set(a.bytes, l);
1698
1803
  return l;
@@ -1710,6 +1815,7 @@ function rasterRenderOptions(ro) {
1710
1815
  };
1711
1816
  }
1712
1817
  var C2 = CANVAS;
1818
+ var PAD = C2 / 4;
1713
1819
  var H2 = C2 / 2;
1714
1820
  var b64Cache = /* @__PURE__ */ new WeakMap();
1715
1821
  var b64 = (bytes) => {
@@ -1728,11 +1834,32 @@ var b64 = (bytes) => {
1728
1834
  };
1729
1835
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
1730
1836
  var n = (x) => +x.toFixed(3);
1731
- function hexToRGBA(hex) {
1732
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
1733
- if (!m) return { r: 0.25, g: 0.65, b: 0.63, a: 1 };
1734
- const v = parseInt(m[1], 16);
1735
- return { r: (v >> 16 & 255) / 255, g: (v >> 8 & 255) / 255, b: (v & 255) / 255, a: 1 };
1837
+ var DEFAULT_TINT = { r: 0.25, g: 0.65, b: 0.63, a: 1 };
1838
+ function tintToRGBA(input) {
1839
+ try {
1840
+ const c = parseColor(normalizeColorInput(input));
1841
+ return c ? { ...toRGBA(c), a: 1 } : DEFAULT_TINT;
1842
+ } catch {
1843
+ return DEFAULT_TINT;
1844
+ }
1845
+ }
1846
+ var num2 = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
1847
+ function safePosition(p) {
1848
+ if (!p || typeof p !== "object") return null;
1849
+ const o = p;
1850
+ const t = Array.isArray(o["translation-in-points"]) ? o["translation-in-points"] : [];
1851
+ const scale = num2(o.scale, 1);
1852
+ return { scale: scale > 0 ? scale : 1, tx: num2(t[0], 0), ty: num2(t[1], 0) };
1853
+ }
1854
+ function safeOrientation(o) {
1855
+ const pt = (v) => {
1856
+ const q = v;
1857
+ return q && typeof q === "object" && Number.isFinite(q.x) && Number.isFinite(q.y) ? { x: q.x, y: q.y } : null;
1858
+ };
1859
+ const r = o;
1860
+ const start = r && typeof r === "object" ? pt(r.start) : null;
1861
+ const stop = r && typeof r === "object" ? pt(r.stop) : null;
1862
+ return start && stop ? { start, stop } : void 0;
1736
1863
  }
1737
1864
  var colorOf = (s) => {
1738
1865
  const c = typeof s === "string" ? parseColor(s) : null;
@@ -1779,10 +1906,10 @@ function paintFor(fill, defs, box, darkSystem) {
1779
1906
  const c = colorOf(f["automatic-gradient"]);
1780
1907
  return lin(shade(c, 0.22), shade(c, -0.08));
1781
1908
  }
1782
- if (Array.isArray(f["linear-gradient"])) {
1909
+ if (Array.isArray(f["linear-gradient"]) && f["linear-gradient"].length) {
1783
1910
  const [a, b] = f["linear-gradient"];
1784
- const o = f.orientation;
1785
- return lin(colorOf(a), colorOf(b), o?.start, o?.stop);
1911
+ const o = safeOrientation(f.orientation);
1912
+ return lin(colorOf(a), colorOf(b ?? a), o?.start, o?.stop);
1786
1913
  }
1787
1914
  return null;
1788
1915
  }
@@ -1795,22 +1922,25 @@ function renderSvg(doc, opts = {}) {
1795
1922
  const dark = isDarkAppearance(appearance);
1796
1923
  const isClear = appearance === "clear-light" || appearance === "clear-dark";
1797
1924
  const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
1798
- const tint = hexToRGBA(opts.tint ?? "#3FA7A0");
1925
+ const tint = opts.tint ? tintToRGBA(opts.tint) : DEFAULT_TINT;
1926
+ const idioms = idiomsFor(ctx.idiom);
1927
+ const forPlatform = (s) => s.idiom === void 0 || idioms.includes(s.idiom);
1799
1928
  const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
1800
1929
  const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
1801
1930
  const defs = new Defs(opts.idPrefix ?? "");
1802
1931
  const body = [];
1803
1932
  const fillProp = doc.props.fill;
1804
1933
  let bgFill = resolveProp(fillProp, ctx, "automatic");
1805
- const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark");
1934
+ const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark" && forPlatform(s));
1806
1935
  let darken = 0;
1807
1936
  if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
1808
1937
  let bgPaint;
1809
- const P = mask === "circle" ? 32 : 0;
1938
+ const P = mask === "circle" || ctx.idiom === "watchOS" ? 32 : 0;
1810
1939
  const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
1811
- const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" || s.appearance === appearance);
1940
+ const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" && forPlatform(s));
1812
1941
  if (monoOverride) {
1813
- bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
1942
+ const mono = resolveProp(fillProp, { appearance: dark ? "tinted-dark" : "tinted-light", idiom: ctx.idiom }, "automatic");
1943
+ bgPaint = paintFor(mono ?? "automatic", defs, full, dark);
1814
1944
  } else if (isClear) {
1815
1945
  bgPaint = dark ? paintFor({ "linear-gradient": ["srgb:0.24,0.25,0.28,0.92", "srgb:0.11,0.12,0.13,0.92"] }, defs, full, true) : paintFor({ "linear-gradient": ["srgb:0.97,0.98,0.99,0.86", "srgb:0.84,0.87,0.91,0.86"] }, defs, full, false);
1816
1946
  } else if (isTinted) {
@@ -1851,7 +1981,10 @@ function renderSvg(doc, opts = {}) {
1851
1981
  plainId = plain;
1852
1982
  }
1853
1983
  function drawGroup(g, backdropId) {
1854
- const gpos = resolveProp(g.props.position, ctx, null);
1984
+ const gp = safePosition(resolveProp(g.props.position, ctx, null));
1985
+ const gs = gp?.scale ?? 1;
1986
+ const gtx = gp?.tx ?? 0;
1987
+ const gty = gp?.ty ?? 0;
1855
1988
  const layers = FRONT_FIRST ? [...g.layers].reverse() : [...g.layers];
1856
1989
  const placed = [];
1857
1990
  for (const l of layers) {
@@ -1859,14 +1992,23 @@ function renderSvg(doc, opts = {}) {
1859
1992
  const name = resolveProp(l.props["image-name"], ctx, "");
1860
1993
  const asset = doc.assets[name];
1861
1994
  if (!asset || asset.kind === "pdf" || asset.kind === "other") continue;
1862
- const pos = resolveProp(l.props.position, ctx, null);
1995
+ if (!(asset.width > 0 && asset.height > 0 && Number.isFinite(asset.width) && Number.isFinite(asset.height))) continue;
1996
+ const pos = safePosition(resolveProp(l.props.position, ctx, null));
1863
1997
  const s = pos?.scale ?? 1;
1864
- const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
1865
1998
  const w = asset.width * s;
1866
1999
  const h = asset.height * s;
1867
- placed.push({ layer: l, asset, x: H2 + tx - w / 2, y: H2 + ty - h / 2, w, h });
2000
+ const x = H2 + (pos?.tx ?? 0) - w / 2;
2001
+ const y = H2 + (pos?.ty ?? 0) - h / 2;
2002
+ const cx0 = H2 + gtx + (x - H2) * gs;
2003
+ const cy0 = H2 + gty + (y - H2) * gs;
2004
+ const cx1 = cx0 + w * gs;
2005
+ const cy1 = cy0 + h * gs;
2006
+ if (cx1 < -P || cy1 < -P || cx0 > C2 + P || cy0 > C2 + P) continue;
2007
+ placed.push({ layer: l, asset, x, y, w, h });
1868
2008
  }
1869
2009
  if (!placed.length) return null;
2010
+ const groupOpacity = Math.min(1, Math.max(0, num2(resolveProp(g.props.opacity, ctx, 1), 1)));
2011
+ if (groupOpacity <= 0) return null;
1870
2012
  const imgId = /* @__PURE__ */ new Map();
1871
2013
  const layerEls = [];
1872
2014
  const glassEls = [];
@@ -1883,7 +2025,7 @@ function renderSvg(doc, opts = {}) {
1883
2025
  const s = p.w / p.asset.width;
1884
2026
  const place = `transform="translate(${n(p.x)} ${n(p.y)}) scale(${n(s)})"`;
1885
2027
  const fill = resolveProp(p.layer.props.fill, ctx, null);
1886
- const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
2028
+ const opacity = Math.min(1, Math.max(0, num2(resolveProp(p.layer.props.opacity, ctx, 1), 1)));
1887
2029
  const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
1888
2030
  const cb = contentBoxOf(p.asset);
1889
2031
  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 };
@@ -1891,7 +2033,7 @@ function renderSvg(doc, opts = {}) {
1891
2033
  let el;
1892
2034
  if (paint) {
1893
2035
  const mid = defs.id("lm");
1894
- defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="-${C2}" y="-${C2}" width="${3 * C2}" height="${3 * C2}"><use href="#${iid}" ${place}/></mask>`);
2036
+ defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"><use href="#${iid}" ${place}/></mask>`);
1895
2037
  el = `<rect x="${n(p.x)}" y="${n(p.y)}" width="${n(p.w)}" height="${n(p.h)}" fill="${paint}" mask="url(#${mid})"/>`;
1896
2038
  } else {
1897
2039
  el = `<use href="#${iid}" ${place}/>`;
@@ -1903,43 +2045,55 @@ function renderSvg(doc, opts = {}) {
1903
2045
  glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
1904
2046
  }
1905
2047
  }
1906
- 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})"` : "";
2048
+ const gTransform = gp ? ` transform="translate(${n(H2 + gtx)} ${n(H2 + gty)}) scale(${n(gs)}) translate(${-H2} ${-H2})"` : "";
2049
+ const canvasRegion = `filterUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"`;
2050
+ const gx0 = H2 + (-PAD - H2 - gtx) / gs;
2051
+ const gy0 = H2 + (-PAD - H2 - gty) / gs;
2052
+ const groupRegion = `filterUnits="userSpaceOnUse" x="${n(gx0)}" y="${n(gy0)}" width="${n((C2 + 2 * PAD) / gs)}" height="${n((C2 + 2 * PAD) / gs)}"`;
1907
2053
  const contentId = defs.id("gc");
1908
2054
  defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
1909
2055
  contentIds.push(contentId);
1910
2056
  const out = [];
1911
- const shadow = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
2057
+ const shadow0 = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
2058
+ const shadow = shadow0 && typeof shadow0 === "object" ? { kind: shadow0.kind, opacity: Math.min(1, Math.max(0, num2(shadow0.opacity, 0.5))) } : null;
1912
2059
  if (effects && shadow && shadow.kind !== "none" && shadow.opacity > 0) {
1913
2060
  const fid = defs.id("sh");
1914
2061
  if (shadow.kind === "neutral") {
1915
2062
  defs.add(
1916
- `<filter id="${fid}" x="-30%" y="-30%" width="160%" height="170%"><feGaussianBlur in="SourceAlpha" stdDeviation="16"/><feOffset dy="14" result="b"/><feFlood flood-color="#000" flood-opacity="0.45"/><feComposite in2="b" operator="in"/></filter>`
2063
+ `<filter id="${fid}" ${canvasRegion}><feGaussianBlur in="SourceAlpha" stdDeviation="16"/><feOffset dy="14" result="b"/><feFlood flood-color="#000" flood-opacity="0.45"/><feComposite in2="b" operator="in"/></filter>`
1917
2064
  );
1918
2065
  } else {
1919
2066
  defs.add(
1920
- `<filter id="${fid}" x="-30%" y="-30%" width="160%" height="170%"><feGaussianBlur in="SourceGraphic" stdDeviation="18"/><feOffset dy="16"/><feColorMatrix type="matrix" values="0.75 0 0 0 0 0 0.75 0 0 0 0 0 0.75 0 0 0 0 0 0.8 0"/></filter>`
2067
+ `<filter id="${fid}" ${canvasRegion}><feGaussianBlur in="SourceGraphic" stdDeviation="18"/><feOffset dy="16"/><feColorMatrix type="matrix" values="0.75 0 0 0 0 0 0.75 0 0 0 0 0 0.75 0 0 0 0 0 0.8 0"/></filter>`
1921
2068
  );
1922
2069
  }
1923
2070
  out.push(`<use href="#${contentId}" filter="url(#${fid})" opacity="${n(shadow.opacity)}"/>`);
1924
2071
  }
1925
- const tr = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
1926
- const blurMat = resolveProp(g.props["blur-material"], ctx, null);
2072
+ const tr0 = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
2073
+ const tr = tr0 && typeof tr0 === "object" ? { enabled: !!tr0.enabled, value: num2(tr0.value, 0.5) } : null;
2074
+ const blurRaw = resolveProp(g.props["blur-material"], ctx, void 0);
2075
+ const blurMat = blurRaw === null ? null : num2(blurRaw, 0.2);
1927
2076
  const translucent = effects && !!tr?.enabled && tr.value > 0;
1928
- const refr = resolveProp(g.props.refractivity, ctx, null);
2077
+ const refr0 = resolveProp(g.props.refractivity, ctx, null);
2078
+ const refr = refr0 && typeof refr0 === "object" ? { enabled: !!refr0.enabled, strength: num2(refr0.strength, 0), depth: num2(refr0.depth, 0) } : null;
1929
2079
  const refracts = effects && !!refr?.enabled && glassEls.length > 0;
1930
2080
  if (translucent || refracts) {
1931
2081
  const maskSrc = translucent ? `<use href="#${contentId}"/>` : `<g${gTransform}>${glassEls.join("")}</g>`;
1932
2082
  const mid = defs.id("gm");
1933
- defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="${C2}" height="${C2}">${maskSrc}</mask>`);
1934
- const fid = defs.id("bk");
1935
- const blurPx = 4 + (blurMat ?? 0.2) * 40 + (refracts ? (refr.depth ?? 0) * 10 : 0);
1936
- defs.add(`<filter id="${fid}" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
1937
- const zoom = 1.02 + (refracts ? (refr.strength ?? 0) * 0.12 : 0.015);
2083
+ defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}">${maskSrc}</mask>`);
2084
+ const blurPx = Math.max(0, (blurMat === null ? 0 : 4 + blurMat * 40) + (refracts ? refr.depth * 10 : 0));
2085
+ let filterAttr = "";
2086
+ if (blurPx > 0) {
2087
+ const fid = defs.id("bk");
2088
+ defs.add(`<filter id="${fid}" ${canvasRegion}><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
2089
+ filterAttr = ` filter="url(#${fid})"`;
2090
+ }
2091
+ const zoom = 1.02 + (refracts ? refr.strength * 0.12 : 0.015);
1938
2092
  out.push(
1939
- `<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
2093
+ `<g mask="url(#${mid})"><use href="#${backdropId}"${filterAttr} transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
1940
2094
  );
1941
2095
  }
1942
- const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
2096
+ const gOpacity = refracts && !translucent ? 1 - Math.min(1, Math.max(0, refr.strength)) * 0.3 : 1;
1943
2097
  let fadeMask = "";
1944
2098
  if (translucent) {
1945
2099
  const v = Math.min(1, Math.max(0, tr.value));
@@ -1965,15 +2119,15 @@ function renderSvg(doc, opts = {}) {
1965
2119
  const fid = defs.id("sp");
1966
2120
  if (specular === "outside") {
1967
2121
  defs.add(
1968
- `<filter id="${fid}" x="-8%" y="-8%" width="116%" height="116%"><feMorphology in="SourceAlpha" operator="dilate" radius="6" result="d"/><feComposite in="d" in2="SourceAlpha" operator="out" result="band"/><feOffset in="SourceAlpha" dx="${n(-dx * 1.6)}" dy="${n(-dy * 1.6)}" result="lit"/><feComposite in="band" in2="lit" operator="in" result="edge"/><feGaussianBlur in="edge" stdDeviation="1.6" result="soft"/><feFlood flood-color="#fff" flood-opacity="0.8"/><feComposite in2="soft" operator="in"/></filter>`
2122
+ `<filter id="${fid}" ${groupRegion}><feMorphology in="SourceAlpha" operator="dilate" radius="6" result="d"/><feComposite in="d" in2="SourceAlpha" operator="out" result="band"/><feOffset in="SourceAlpha" dx="${n(-dx * 1.6)}" dy="${n(-dy * 1.6)}" result="lit"/><feComposite in="band" in2="lit" operator="in" result="edge"/><feGaussianBlur in="edge" stdDeviation="1.6" result="soft"/><feFlood flood-color="#fff" flood-opacity="0.8"/><feComposite in2="soft" operator="in"/></filter>`
1969
2123
  );
1970
2124
  } else defs.add(
1971
- `<filter id="${fid}" x="-5%" y="-5%" width="110%" height="110%"><feOffset in="SourceAlpha" dx="${dx}" dy="${dy}" result="sh"/><feComposite in="SourceAlpha" in2="sh" operator="out" result="edge"/><feGaussianBlur in="edge" stdDeviation="1.6" result="soft"/><feFlood flood-color="#fff" flood-opacity="0.85"/><feComposite in2="soft" operator="in" result="hi"/><feOffset in="SourceAlpha" dx="${n(-dx)}" dy="${n(-dy)}" result="sh2"/><feComposite in="SourceAlpha" in2="sh2" operator="out" result="edge2"/><feGaussianBlur in="edge2" stdDeviation="2" result="soft2"/><feFlood flood-color="#fff" flood-opacity="0.28"/><feComposite in2="soft2" operator="in" result="lo"/><feMerge><feMergeNode in="hi"/><feMergeNode in="lo"/></feMerge></filter>`
2125
+ `<filter id="${fid}" ${groupRegion}><feOffset in="SourceAlpha" dx="${dx}" dy="${dy}" result="sh"/><feComposite in="SourceAlpha" in2="sh" operator="out" result="edge"/><feGaussianBlur in="edge" stdDeviation="1.6" result="soft"/><feFlood flood-color="#fff" flood-opacity="0.85"/><feComposite in2="soft" operator="in" result="hi"/><feOffset in="SourceAlpha" dx="${n(-dx)}" dy="${n(-dy)}" result="sh2"/><feComposite in="SourceAlpha" in2="sh2" operator="out" result="edge2"/><feGaussianBlur in="edge2" stdDeviation="2" result="soft2"/><feFlood flood-color="#fff" flood-opacity="0.28"/><feComposite in2="soft2" operator="in" result="lo"/><feMerge><feMergeNode in="hi"/><feMergeNode in="lo"/></feMerge></filter>`
1972
2126
  );
1973
2127
  const glass = individual ? glassEls.map((e) => `<g filter="url(#${fid})">${e}</g>`).join("") : `<g filter="url(#${fid})">${glassEls.join("")}</g>`;
1974
2128
  out.push(`<g${gTransform}>${glass}</g>`);
1975
2129
  }
1976
- return out.join("");
2130
+ return groupOpacity < 1 ? `<g opacity="${n(groupOpacity)}">${out.join("")}</g>` : out.join("");
1977
2131
  }
1978
2132
  let top = `<use href="#${stackId}"/>`;
1979
2133
  if ((isClear || isTinted) && contentIds.length) {
@@ -1995,18 +2149,19 @@ function renderSvg(doc, opts = {}) {
1995
2149
  }
1996
2150
  const mid = defs.id("fm");
1997
2151
  defs.add(
1998
- `<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="${C2}" height="${C2}">` + contentIds.map((id) => `<use href="#${id}"/>`).join("") + `</mask>`
2152
+ `<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}">` + contentIds.map((id) => `<use href="#${id}"/>`).join("") + `</mask>`
1999
2153
  );
2000
2154
  top = `<use href="#${bgStackId}"/><g filter="url(#${fid})"><use href="#${stackId}" mask="url(#${mid})"/></g>`;
2001
2155
  }
2002
2156
  const clipId = defs.id("clip");
2003
- const shape = maskPath(mask);
2157
+ const shape = maskPath(mask, P);
2004
2158
  defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
2005
- const M = Math.max(0, opts.inset ?? 0);
2159
+ const inset = Math.min(C2 / 2 - 1, Math.max(0, num2(opts.inset, 0)));
2160
+ const M = inset > 0 ? inset * C2 / (C2 - 2 * inset) : 0;
2006
2161
  if (M > 0) {
2007
2162
  const sid = defs.id("ish");
2008
2163
  defs.add(
2009
- `<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>`
2164
+ `<filter id="${sid}" ${'filterUnits="userSpaceOnUse"'} x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"><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>`
2010
2165
  );
2011
2166
  body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
2012
2167
  } else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
@@ -2021,8 +2176,9 @@ function renderSvg(doc, opts = {}) {
2021
2176
  }
2022
2177
  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>`;
2023
2178
  }
2179
+ var KNOWN_BLEND = new Set(BLEND_MODES);
2024
2180
  function cssBlend(mode) {
2025
- if (!mode || mode === "normal") return null;
2181
+ if (typeof mode !== "string" || !KNOWN_BLEND.has(mode) || mode === "normal") return null;
2026
2182
  if (mode === "plus-lighter") return "screen";
2027
2183
  if (mode === "plus-darker") return "multiply";
2028
2184
  return mode;
@@ -2065,6 +2221,7 @@ async function resvg() {
2065
2221
  }
2066
2222
  }
2067
2223
  async function svgToPng(svg, width) {
2224
+ if (!(width >= 2)) throw new Error(`Image size must be at least 2 px (got ${width})`);
2068
2225
  const { Resvg } = await resvg();
2069
2226
  const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
2070
2227
  return new Uint8Array(r.render().asPng());
@@ -2112,6 +2269,7 @@ var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.arr
2112
2269
  function describeProps(props) {
2113
2270
  const base = {};
2114
2271
  const overrides = {};
2272
+ const variants = /* @__PURE__ */ new Map();
2115
2273
  for (const [k, p] of Object.entries(props)) {
2116
2274
  const b = p.specs?.find((s) => s.appearance === void 0 && s.idiom === void 0);
2117
2275
  if (b) base[k] = b.value;
@@ -2120,21 +2278,32 @@ function describeProps(props) {
2120
2278
  const s = p.specs.find((e) => e.appearance === a && e.idiom === void 0);
2121
2279
  (overrides[a] ??= {})[k] = s.value;
2122
2280
  }
2281
+ for (const s of p.specs ?? []) {
2282
+ if (s.idiom === void 0) continue;
2283
+ const key = `${s.appearance ?? ""}|${s.idiom}`;
2284
+ if (!variants.has(key)) variants.set(key, { ...s.appearance ? { appearance: s.appearance } : {}, platform: s.idiom, values: {} });
2285
+ variants.get(key).values[k] = s.value;
2286
+ }
2123
2287
  }
2124
- return Object.keys(overrides).length ? { ...base, overrides } : base;
2288
+ return {
2289
+ ...base,
2290
+ ...Object.keys(overrides).length ? { overrides } : {},
2291
+ ...variants.size ? { variants: [...variants.values()] } : {}
2292
+ };
2125
2293
  }
2126
2294
  function inspect(doc) {
2127
2295
  return {
2128
2296
  order: "groups and layers are listed front to back (index 0 = frontmost)",
2129
2297
  document: describeProps(doc.props),
2298
+ // attributes first, so an unknown key in the file (e.g. "target") can't replace these fields
2130
2299
  groups: doc.groups.map((g, gi) => ({
2300
+ ...describeProps(g.props),
2131
2301
  target: nameOf(g) || `#${gi}`,
2132
2302
  index: gi,
2133
- ...describeProps(g.props),
2134
2303
  layers: g.layers.map((l, li) => ({
2304
+ ...describeProps(l.props),
2135
2305
  target: `${nameOf(g) || `#${gi}`}/${nameOf(l) || `#${li}`}`,
2136
- index: li,
2137
- ...describeProps(l.props)
2306
+ index: li
2138
2307
  }))
2139
2308
  })),
2140
2309
  assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
@@ -2152,9 +2321,7 @@ function targetLabel(doc, ref) {
2152
2321
  }
2153
2322
  async function saveFile(ws, out, bytes) {
2154
2323
  const p = await ws.resolve(out);
2155
- await mkdir2(dirname2(p), { recursive: true });
2156
- await writeFile2(p, bytes);
2157
- return p;
2324
+ return (await writeFiles(dirname2(p), { [basename(p)]: bytes }))[0];
2158
2325
  }
2159
2326
  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() })) });
2160
2327
  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>';
@@ -2179,7 +2346,9 @@ var TOOLS = [
2179
2346
  },
2180
2347
  output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
2181
2348
  mutates_input: true,
2349
+ writes_files: true,
2182
2350
  idempotency_hint: "not-idempotent",
2351
+ destructive: true,
2183
2352
  supports_dry_run: true,
2184
2353
  verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
2185
2354
  requires_visual_verification: false,
@@ -2187,14 +2356,16 @@ var TOOLS = [
2187
2356
  async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
2188
2357
  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)");
2189
2358
  const full = await ws.iconPath(path);
2190
- const exists = await stat2(full).then(() => true, () => false);
2191
- if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
2192
2359
  const doc = createDocument({
2193
2360
  ...background ? { background } : {},
2194
2361
  platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
2195
2362
  });
2196
2363
  if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
2197
- if (!dry_run) await ws.withLock(full, () => saveIcon(full, doc));
2364
+ await ws.withLock(full, async () => {
2365
+ const exists = await stat2(full).then(() => true, () => false);
2366
+ if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
2367
+ if (!dry_run) await saveIcon(full, doc);
2368
+ });
2198
2369
  return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
2199
2370
  }
2200
2371
  }),
@@ -2210,6 +2381,7 @@ var TOOLS = [
2210
2381
  input: { path: pathArg },
2211
2382
  output: inspectOutput,
2212
2383
  mutates_input: false,
2384
+ writes_files: false,
2213
2385
  idempotency_hint: "idempotent",
2214
2386
  supports_dry_run: false,
2215
2387
  verification: [],
@@ -2238,6 +2410,7 @@ var TOOLS = [
2238
2410
  },
2239
2411
  output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
2240
2412
  mutates_input: true,
2413
+ writes_files: false,
2241
2414
  idempotency_hint: "not-idempotent",
2242
2415
  supports_dry_run: true,
2243
2416
  verification: ["icon_inspect lists the new group"],
@@ -2280,6 +2453,7 @@ var TOOLS = [
2280
2453
  },
2281
2454
  output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
2282
2455
  mutates_input: true,
2456
+ writes_files: false,
2283
2457
  idempotency_hint: "not-idempotent",
2284
2458
  supports_dry_run: true,
2285
2459
  verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
@@ -2355,6 +2529,7 @@ var TOOLS = [
2355
2529
  },
2356
2530
  output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
2357
2531
  mutates_input: true,
2532
+ writes_files: false,
2358
2533
  idempotency_hint: "idempotent",
2359
2534
  supports_dry_run: true,
2360
2535
  verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
@@ -2390,6 +2565,7 @@ var TOOLS = [
2390
2565
  input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
2391
2566
  output: loose({ groups: groupsOutline }),
2392
2567
  mutates_input: true,
2568
+ writes_files: false,
2393
2569
  idempotency_hint: "idempotent",
2394
2570
  supports_dry_run: true,
2395
2571
  verification: ["`groups` lists the new order (front to back)"],
@@ -2403,6 +2579,7 @@ var TOOLS = [
2403
2579
  const ref = resolvePath(doc, target);
2404
2580
  let gid;
2405
2581
  if (to_group) {
2582
+ if (ref.kind !== "layer") throw new Error("to_group only applies to layers; groups are reordered with index");
2406
2583
  const g = resolvePath(doc, to_group);
2407
2584
  if (g.kind !== "group") throw new Error("to_group must be a group");
2408
2585
  gid = g.id;
@@ -2434,6 +2611,7 @@ var TOOLS = [
2434
2611
  },
2435
2612
  output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
2436
2613
  mutates_input: true,
2614
+ writes_files: false,
2437
2615
  idempotency_hint: "idempotent",
2438
2616
  supports_dry_run: true,
2439
2617
  verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
@@ -2469,6 +2647,7 @@ var TOOLS = [
2469
2647
  input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
2470
2648
  output: loose({ removed: z2.string(), groups: groupsOutline }),
2471
2649
  mutates_input: true,
2650
+ writes_files: false,
2472
2651
  idempotency_hint: "not-idempotent",
2473
2652
  destructive: true,
2474
2653
  supports_dry_run: true,
@@ -2505,6 +2684,7 @@ var TOOLS = [
2505
2684
  issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
2506
2685
  }),
2507
2686
  mutates_input: false,
2687
+ writes_files: false,
2508
2688
  idempotency_hint: "idempotent",
2509
2689
  supports_dry_run: false,
2510
2690
  verification: ['No issue with severity "error"'],
@@ -2531,12 +2711,13 @@ var TOOLS = [
2531
2711
  platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
2532
2712
  size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
2533
2713
  mask: maskEnum.optional(),
2534
- tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2714
+ tint: z2.string().regex(/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, 'tint must be a color like "#33AAFF"').optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2535
2715
  light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
2536
2716
  out: z2.string().optional().describe("Save the PNG to this path")
2537
2717
  },
2538
2718
  output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
2539
2719
  mutates_input: false,
2720
+ writes_files: true,
2540
2721
  idempotency_hint: "idempotent",
2541
2722
  supports_dry_run: false,
2542
2723
  verification: ["Look at the returned image"],
@@ -2569,11 +2750,12 @@ var TOOLS = [
2569
2750
  input: {
2570
2751
  path: pathArg,
2571
2752
  cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
2572
- tint: z2.string().optional(),
2753
+ tint: z2.string().regex(/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, 'tint must be a color like "#33AAFF"').optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2573
2754
  out: z2.string().optional()
2574
2755
  },
2575
2756
  output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
2576
2757
  mutates_input: false,
2758
+ writes_files: true,
2577
2759
  idempotency_hint: "idempotent",
2578
2760
  supports_dry_run: false,
2579
2761
  verification: ["Look at the returned image: every appearance should stay legible"],
@@ -2599,12 +2781,14 @@ var TOOLS = [
2599
2781
  path: pathArg,
2600
2782
  out_dir: z2.string(),
2601
2783
  formats: z2.array(z2.enum(["icon", "icns", "appiconset", "png"])).min(1),
2602
- sizes: z2.array(z2.number().int().min(16).max(2048)).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2603
- appearances: z2.array(appearanceEnum).optional().describe("PNG appearances, default all six")
2784
+ sizes: z2.array(z2.number().int().min(16).max(2048)).min(1).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2785
+ appearances: z2.array(appearanceEnum).min(1).optional().describe("PNG appearances, default all six")
2604
2786
  },
2605
2787
  output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
2606
2788
  mutates_input: false,
2789
+ writes_files: true,
2607
2790
  idempotency_hint: "idempotent",
2791
+ destructive: true,
2608
2792
  supports_dry_run: false,
2609
2793
  verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
2610
2794
  requires_visual_verification: false,
@@ -2612,6 +2796,9 @@ var TOOLS = [
2612
2796
  async run(ws, { path, out_dir, formats, sizes, appearances }) {
2613
2797
  const full = await ws.iconPath(path, { mustExist: true });
2614
2798
  const outDir = await ws.resolve(out_dir);
2799
+ const inside = relative2(full, outDir);
2800
+ if (inside === "" || !inside.startsWith(`..${sep2}`) && inside !== ".." && !isAbsolute2(inside))
2801
+ throw new Error("out_dir is inside the icon bundle; choose a folder outside it");
2615
2802
  const doc = await ws.read(full);
2616
2803
  const name = basename(full).replace(/\.icon(\.zip)?$/, "");
2617
2804
  const raster = nodeRasterizer(doc);
@@ -2620,16 +2807,9 @@ var TOOLS = [
2620
2807
  throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them first (icon_validate).`);
2621
2808
  const written = [];
2622
2809
  if (formats.includes("icon")) {
2623
- const dest = join2(outDir, `${name}.icon`);
2624
- await saveIcon(dest, doc);
2625
- written.push(join2(dest, "icon.json"), ...Object.keys(doc.assets).map((a) => join2(dest, "Assets", a)));
2626
- }
2627
- if (formats.includes("icns")) {
2628
- const p = join2(outDir, `${name}.icns`);
2629
- await mkdir2(dirname2(p), { recursive: true });
2630
- await writeFile2(p, await buildIcns(raster));
2631
- written.push(p);
2810
+ written.push(...await saveIcon(join2(outDir, `${name}.icon`), doc));
2632
2811
  }
2812
+ if (formats.includes("icns")) written.push(...await writeFiles(outDir, { [`${name}.icns`]: await buildIcns(raster) }));
2633
2813
  if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
2634
2814
  if (formats.includes("png"))
2635
2815
  written.push(
@@ -2646,7 +2826,7 @@ async function runTool(ws, t, args) {
2646
2826
  }
2647
2827
 
2648
2828
  // src/version.ts
2649
- var VERSION = "0.2.0";
2829
+ var VERSION = "0.2.1";
2650
2830
 
2651
2831
  // src/contract.ts
2652
2832
  var RESOURCES = [
@@ -2683,6 +2863,7 @@ function toolSpec(t) {
2683
2863
  input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
2684
2864
  output_schema: jsonSchema(t.output, "output"),
2685
2865
  mutates_input: t.mutates_input,
2866
+ writes_files: t.writes_files,
2686
2867
  idempotency_hint: t.idempotency_hint,
2687
2868
  destructive: !!t.destructive,
2688
2869
  supports_dry_run: t.supports_dry_run,
@@ -2715,7 +2896,7 @@ function buildContract() {
2715
2896
  rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
2716
2897
  },
2717
2898
  tools: TOOLS.map(toolSpec),
2718
- capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a.localeCompare(b))),
2899
+ capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
2719
2900
  resources: RESOURCES,
2720
2901
  prompts: PROMPTS,
2721
2902
  icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
@@ -2809,10 +2990,11 @@ function createServer(ws) {
2809
2990
  {
2810
2991
  title: t.title,
2811
2992
  description: t.description,
2812
- inputSchema: t.input,
2993
+ // strict: a misspelt argument (dryRun for dry_run) is an error, not silently dropped
2994
+ inputSchema: z4.object(t.input).strict(),
2813
2995
  outputSchema: t.output,
2814
2996
  annotations: {
2815
- readOnlyHint: !t.mutates_input,
2997
+ readOnlyHint: !t.mutates_input && !t.writes_files,
2816
2998
  destructiveHint: !!t.destructive,
2817
2999
  idempotentHint: t.idempotency_hint === "idempotent",
2818
3000
  openWorldHint: false
@@ -2886,8 +3068,8 @@ Use the fresnel tools:
2886
3068
  }
2887
3069
 
2888
3070
  // src/workspace.ts
2889
- import { realpath, stat as stat3 } from "fs/promises";
2890
- import { dirname as dirname3, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
3071
+ import { lstat as lstat2, realpath, stat as stat3 } from "fs/promises";
3072
+ import { dirname as dirname3, isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
2891
3073
  var Workspace = class {
2892
3074
  roots;
2893
3075
  locks = /* @__PURE__ */ new Map();
@@ -2900,13 +3082,17 @@ var Workspace = class {
2900
3082
  /** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
2901
3083
  async resolve(p, opts = {}) {
2902
3084
  if (!p || typeof p !== "string") throw new Error("A path is required");
2903
- const abs = isAbsolute2(p) ? resolve2(p) : resolve2(this.roots[0], p);
3085
+ const abs = isAbsolute3(p) ? resolve2(p) : resolve2(this.roots[0], p);
2904
3086
  let probe = abs;
2905
3087
  let suffix = "";
2906
3088
  for (; ; ) {
2907
3089
  try {
2908
3090
  const real = await realpath(probe);
2909
3091
  const full = suffix ? resolve2(real, suffix) : real;
3092
+ if (suffix) {
3093
+ const first = resolve2(real, suffix.split(sep3)[0]);
3094
+ if (await lstat2(first).then(() => true, () => false)) throw new Error(`Access denied: ${p} goes through a symbolic link that points nowhere`);
3095
+ }
2910
3096
  if (!await this.allowed(full)) throw new Error(`Access denied: ${p} is outside the allowed roots (${this.roots.join(", ")})`);
2911
3097
  if (opts.mustExist && full !== real) throw new Error(`Not found: ${p}`);
2912
3098
  return full;
@@ -2915,7 +3101,7 @@ var Workspace = class {
2915
3101
  if (opts.mustExist) throw new Error(`Not found: ${p}`);
2916
3102
  const parent = dirname3(probe);
2917
3103
  if (parent === probe) throw new Error(`Invalid path: ${p}`);
2918
- suffix = suffix ? `${probe.slice(parent.length + 1)}${sep2}${suffix}` : probe.slice(parent.length + 1);
3104
+ suffix = suffix ? `${probe.slice(parent.length + 1)}${sep3}${suffix}` : probe.slice(parent.length + 1);
2919
3105
  probe = parent;
2920
3106
  }
2921
3107
  }
@@ -2928,15 +3114,17 @@ var Workspace = class {
2928
3114
  root = await realpath(r);
2929
3115
  } catch {
2930
3116
  }
2931
- const rel = relative2(root, full);
2932
- if (rel === "" || !rel.startsWith("..") && !isAbsolute2(rel)) return true;
3117
+ const rel = relative3(root, full);
3118
+ if (rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute3(rel)) return true;
2933
3119
  }
2934
3120
  return false;
2935
3121
  }
2936
3122
  /** Normalizes an icon path: accepts "Foo", "Foo.icon", ".../Foo.icon/icon.json". */
2937
3123
  async iconPath(p, opts = {}) {
2938
- let q = p.trim();
3124
+ let q = (typeof p === "string" ? p : "").trim().replace(/[\\/]+$/, "");
2939
3125
  if (q.endsWith("/icon.json") || q.endsWith("\\icon.json")) q = q.slice(0, -"/icon.json".length);
3126
+ const base = q.split(/[\\/]/).pop() ?? "";
3127
+ if (!base || base === ".icon" || base === ".icon.zip" || base === "." || base === "..") throw new Error(`Invalid icon path "${p}": give a name such as "MyApp.icon"`);
2940
3128
  if (!q.endsWith(".icon") && !q.endsWith(".icon.zip")) q = `${q}.icon`;
2941
3129
  const full = await this.resolve(q, opts);
2942
3130
  if (opts.mustExist) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fresnel-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "MCP server for fresnel: let AI agents create, edit, preview and export Liquid Glass app icons (.icon bundles)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,8 +38,8 @@
38
38
  "devDependencies": {
39
39
  "tsup": "9.0.0",
40
40
  "tsx": "4.23.15",
41
- "@fresnel/render": "0.2.0",
42
- "@fresnel/core": "0.2.0"
41
+ "@fresnel/core": "0.2.1",
42
+ "@fresnel/render": "0.2.1"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsup",