fresnel-mcp 0.2.0 → 0.2.2

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 +324 -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);
455
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]);
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
  }
@@ -995,6 +1056,11 @@ function addLayer(doc, groupId, asset, opts = {}) {
995
1056
  return l;
996
1057
  }
997
1058
  function setProp(doc, ref, key, value, appearance, detached) {
1059
+ const old = key === "image-name" && !detached && ref.kind === "layer" ? imagesOf([findLayer(doc, ref.id)?.layer].filter((l) => !!l)) : null;
1060
+ setPropRaw(doc, ref, key, value, appearance, detached);
1061
+ if (old?.size) pruneAssets(doc, old);
1062
+ }
1063
+ function setPropRaw(doc, ref, key, value, appearance, detached) {
998
1064
  const node = detached ? { kind: "layer", props: detached.props } : nodeOf(doc, ref);
999
1065
  if (key === "layers" || key === "groups") throw new OpError(`"${key}" can't be set directly`);
1000
1066
  if (key.endsWith("-specializations")) throw new OpError(`Set "${key.replace(/-specializations$/, "")}" with an appearance instead`);
@@ -1023,31 +1089,40 @@ function normalizeFillColors(f) {
1023
1089
  const o = { ...f };
1024
1090
  if (typeof o.solid === "string") o.solid = normalizeColorInput(o.solid);
1025
1091
  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);
1092
+ if (Array.isArray(o["linear-gradient"])) {
1093
+ if (!o["linear-gradient"].every((c) => typeof c === "string")) throw new OpError("linear-gradient takes a list of color strings");
1094
+ o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
1095
+ }
1027
1096
  return o;
1028
1097
  }
1029
1098
  function removeNode(doc, ref) {
1030
1099
  if (ref.kind === "document") throw new OpError("The document can't be removed");
1100
+ let removedImages;
1031
1101
  if (ref.kind === "group") {
1032
1102
  const i = doc.groups.findIndex((g) => g.id === ref.id);
1033
1103
  if (i < 0) throw new OpError("Group not found");
1104
+ removedImages = imagesOf(doc.groups[i].layers);
1034
1105
  doc.groups.splice(i, 1);
1035
1106
  } else {
1036
1107
  const f = findLayer(doc, ref.id);
1037
1108
  if (!f) throw new OpError("Layer not found");
1109
+ removedImages = imagesOf([f.layer]);
1038
1110
  f.group.layers.splice(f.index, 1);
1039
1111
  }
1040
- pruneAssets(doc);
1112
+ pruneAssets(doc, removedImages);
1041
1113
  }
1042
- function pruneAssets(doc) {
1114
+ function imagesOf(layers) {
1043
1115
  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];
1116
+ for (const l of layers) {
1117
+ const p = l.props["image-name"];
1118
+ if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
1119
+ for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
1120
+ }
1121
+ return used;
1122
+ }
1123
+ function pruneAssets(doc, only) {
1124
+ const used = imagesOf(doc.groups.flatMap((g) => g.layers));
1125
+ for (const k of Object.keys(doc.assets)) if (!used.has(k) && (!only || only.has(k))) delete doc.assets[k];
1051
1126
  }
1052
1127
  function moveNode(doc, ref, index, toGroupId) {
1053
1128
  if (ref.kind === "document") throw new OpError("The document can't be moved");
@@ -1114,9 +1189,10 @@ function validate(doc) {
1114
1189
  let anyTinted = false;
1115
1190
  const scan = (props) => {
1116
1191
  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;
1192
+ for (const v of variantsOf(p)) {
1193
+ if (v.appearance === "dark") anyDark = true;
1194
+ if (v.appearance === "tinted") anyTinted = true;
1195
+ }
1120
1196
  }
1121
1197
  };
1122
1198
  scan(doc.props);
@@ -1147,19 +1223,22 @@ function validate(doc) {
1147
1223
  }
1148
1224
  if (Math.abs(a.width - a.height) > 0.5) {
1149
1225
  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);
1226
+ } else if (a.kind !== "svg" && pos.scale > 1.01) {
1227
+ 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
1228
  }
1153
1229
  if (a.kind === "png" && a.width === a.height && a.width >= 256) {
1154
1230
  const bmp = safeDecode(a.bytes);
1155
1231
  if (bmp && looksPreMasked(bmp)) push("V-04", `"${n2}" seems to have rounded corners baked in. The system masks icons itself.`, lName, lref);
1156
1232
  }
1157
1233
  if (a.kind === "svg") {
1158
- const f = svgFacts(assetText(a));
1234
+ const text2 = assetText(a);
1235
+ const f = svgFacts(text2);
1236
+ const vb = /<svg\b[^>]*\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)/i.exec(text2);
1237
+ const unit2 = vb && Number(vb[1]) > 0 ? svgSize(text2).width / Number(vb[1]) : 1;
1159
1238
  if (f.hasText) push("V-07", `"${n2}" contains <text>. Convert text to outlines.`, lName, lref);
1160
1239
  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);
1240
+ if (f.minStrokeWidth !== null && f.minStrokeWidth * unit2 * pos.scale < 10)
1241
+ 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
1242
  }
1164
1243
  }
1165
1244
  }
@@ -1375,10 +1454,24 @@ function moveBy(doc, ref, dx, dy, ctx, variant) {
1375
1454
  props = f.layer.props;
1376
1455
  scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
1377
1456
  }
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
1457
  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);
1458
+ const shift = (p) => {
1459
+ const q = p ?? { scale: 1, "translation-in-points": [0, 0] };
1460
+ const [x, y] = q["translation-in-points"] ?? [0, 0];
1461
+ return { ...q, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] };
1462
+ };
1463
+ const vv = toVariant(variant);
1464
+ if (vv.appearance !== void 0 || vv.idiom !== void 0) {
1465
+ const cur = variantValue(props.position, vv) ?? resolveProp(props.position, ctx, null);
1466
+ setProp(doc, ref, "position", shift(cur), variant);
1467
+ return;
1468
+ }
1469
+ const pos = props.position;
1470
+ setProp(doc, ref, "position", shift(baseValue(pos)), void 0);
1471
+ for (const e of pos?.specs ?? []) {
1472
+ if (e.appearance === void 0 && e.idiom === void 0) continue;
1473
+ if (e.value && typeof e.value === "object") e.value = shift(e.value);
1474
+ }
1382
1475
  }
1383
1476
  function alignNodes(doc, refs, how, ctx, opts = {}) {
1384
1477
  const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
@@ -1495,8 +1588,8 @@ function exampleCommand(t, args) {
1495
1588
  }
1496
1589
 
1497
1590
  // 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";
1591
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
1592
+ import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, sep as sep2 } from "path";
1500
1593
  import { z as z2 } from "zod";
1501
1594
 
1502
1595
  // ../core/src/node.ts
@@ -1540,6 +1633,7 @@ async function saveIcon(path, doc) {
1540
1633
  const assetsDir = join(root, "Assets");
1541
1634
  await assertNoSymlink(root, assetsDir);
1542
1635
  await mkdir(assetsDir, { recursive: true });
1636
+ const written = [];
1543
1637
  for (const [rel, bytes] of Object.entries(files)) {
1544
1638
  const target = resolve(root, ...rel.split("/"));
1545
1639
  await assertNoSymlink(root, target);
@@ -1549,16 +1643,20 @@ async function saveIcon(path, doc) {
1549
1643
  await rm(tmp, { force: true });
1550
1644
  await writeFile(tmp, bytes, { flag: "wx" });
1551
1645
  await rename(tmp, target);
1646
+ written.push(target);
1552
1647
  }
1553
1648
  const keep = new Set(Object.keys(doc.assets));
1554
1649
  await assertNoSymlink(root, assetsDir);
1555
1650
  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));
1651
+ if (!e.isFile() || keep.has(e.name)) continue;
1652
+ if (e.name.endsWith(".fresnel-tmp") || isSafeBundlePath(`Assets/${e.name}`) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1557
1653
  }
1654
+ return written;
1558
1655
  }
1559
1656
  async function writeFiles(dir, files) {
1560
1657
  const written = [];
1561
1658
  const root = resolve(dir);
1659
+ if ((await lstat(root).catch(() => null))?.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${dir}`);
1562
1660
  for (const [rel, bytes] of Object.entries(files)) {
1563
1661
  const target = resolve(root, ...rel.split("/"));
1564
1662
  const r = relative(root, target);
@@ -1609,7 +1707,7 @@ function contentBoxOf(asset) {
1609
1707
  }
1610
1708
  function svgAssetsNeedingBounds(doc) {
1611
1709
  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);
1710
+ 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
1711
  for (const g of doc.groups)
1614
1712
  for (const l of g.layers) {
1615
1713
  if (!hasGradient(l.props.fill)) continue;
@@ -1638,12 +1736,12 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1638
1736
  }
1639
1737
  var SQUIRCLE = squirclePath();
1640
1738
  var C = CANVAS;
1641
- function maskPath(shape) {
1739
+ function maskPath(shape, pad = 0) {
1642
1740
  if (shape === "circle") {
1643
1741
  const r = C / 2 + 32;
1644
1742
  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
1743
  }
1646
- if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
1744
+ if (shape === "none") return `M${-pad},${-pad} H${C + pad} V${C + pad} H${-pad}Z`;
1647
1745
  return SQUIRCLE;
1648
1746
  }
1649
1747
 
@@ -1669,6 +1767,17 @@ function hexLuma(hex) {
1669
1767
  const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
1670
1768
  return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
1671
1769
  }
1770
+ var NAMED = { white: 1, black: 0 };
1771
+ function paintLuma(v) {
1772
+ if (v.startsWith("#")) return hexLuma(v);
1773
+ const m = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/i.exec(v);
1774
+ if (m) {
1775
+ const ch = (x) => Math.min(1, Math.max(0, x.endsWith("%") ? Number(x.slice(0, -1)) / 100 : Number(x) / 255));
1776
+ return luminance({ r: ch(m[1]), g: ch(m[2]), b: ch(m[3]), a: 1 });
1777
+ }
1778
+ const named = NAMED[v.toLowerCase()];
1779
+ return named ?? null;
1780
+ }
1672
1781
  function assetLuma(a) {
1673
1782
  const hit = cache2.get(a.bytes);
1674
1783
  if (hit !== void 0) return hit;
@@ -1689,10 +1798,11 @@ function assetLuma(a) {
1689
1798
  }
1690
1799
  } else if (a.kind === "svg") {
1691
1800
  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;
1801
+ const paint = /(?:fill|stop-color)\s*[:=]\s*["']?\s*(rgba?\([^)]*\)|[^"';>\s]+)/gi;
1802
+ const values = [...text2.matchAll(paint)].map((m) => m[1].trim()).filter((v) => !/^(none|transparent|inherit|currentcolor|url\()/i.test(v));
1803
+ const ls = values.map(paintLuma).filter((x) => x !== null);
1804
+ if (ls.length) l = ls.reduce((x, y) => x + y, 0) / ls.length;
1805
+ else l = values.length ? 0.5 : 0;
1696
1806
  }
1697
1807
  cache2.set(a.bytes, l);
1698
1808
  return l;
@@ -1710,6 +1820,7 @@ function rasterRenderOptions(ro) {
1710
1820
  };
1711
1821
  }
1712
1822
  var C2 = CANVAS;
1823
+ var PAD = C2 / 4;
1713
1824
  var H2 = C2 / 2;
1714
1825
  var b64Cache = /* @__PURE__ */ new WeakMap();
1715
1826
  var b64 = (bytes) => {
@@ -1728,11 +1839,32 @@ var b64 = (bytes) => {
1728
1839
  };
1729
1840
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
1730
1841
  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 };
1842
+ var DEFAULT_TINT = { r: 0.25, g: 0.65, b: 0.63, a: 1 };
1843
+ function tintToRGBA(input) {
1844
+ try {
1845
+ const c = parseColor(normalizeColorInput(input));
1846
+ return c ? { ...toRGBA(c), a: 1 } : DEFAULT_TINT;
1847
+ } catch {
1848
+ return DEFAULT_TINT;
1849
+ }
1850
+ }
1851
+ var num2 = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
1852
+ function safePosition(p) {
1853
+ if (!p || typeof p !== "object") return null;
1854
+ const o = p;
1855
+ const t = Array.isArray(o["translation-in-points"]) ? o["translation-in-points"] : [];
1856
+ const scale = num2(o.scale, 1);
1857
+ return { scale: scale > 0 ? scale : 1, tx: num2(t[0], 0), ty: num2(t[1], 0) };
1858
+ }
1859
+ function safeOrientation(o) {
1860
+ const pt = (v) => {
1861
+ const q = v;
1862
+ return q && typeof q === "object" && Number.isFinite(q.x) && Number.isFinite(q.y) ? { x: q.x, y: q.y } : null;
1863
+ };
1864
+ const r = o;
1865
+ const start = r && typeof r === "object" ? pt(r.start) : null;
1866
+ const stop = r && typeof r === "object" ? pt(r.stop) : null;
1867
+ return start && stop ? { start, stop } : void 0;
1736
1868
  }
1737
1869
  var colorOf = (s) => {
1738
1870
  const c = typeof s === "string" ? parseColor(s) : null;
@@ -1779,10 +1911,10 @@ function paintFor(fill, defs, box, darkSystem) {
1779
1911
  const c = colorOf(f["automatic-gradient"]);
1780
1912
  return lin(shade(c, 0.22), shade(c, -0.08));
1781
1913
  }
1782
- if (Array.isArray(f["linear-gradient"])) {
1914
+ if (Array.isArray(f["linear-gradient"]) && f["linear-gradient"].length) {
1783
1915
  const [a, b] = f["linear-gradient"];
1784
- const o = f.orientation;
1785
- return lin(colorOf(a), colorOf(b), o?.start, o?.stop);
1916
+ const o = safeOrientation(f.orientation);
1917
+ return lin(colorOf(a), colorOf(b ?? a), o?.start, o?.stop);
1786
1918
  }
1787
1919
  return null;
1788
1920
  }
@@ -1795,22 +1927,25 @@ function renderSvg(doc, opts = {}) {
1795
1927
  const dark = isDarkAppearance(appearance);
1796
1928
  const isClear = appearance === "clear-light" || appearance === "clear-dark";
1797
1929
  const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
1798
- const tint = hexToRGBA(opts.tint ?? "#3FA7A0");
1930
+ const tint = opts.tint ? tintToRGBA(opts.tint) : DEFAULT_TINT;
1931
+ const idioms = idiomsFor(ctx.idiom);
1932
+ const forPlatform = (s) => s.idiom === void 0 || idioms.includes(s.idiom);
1799
1933
  const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
1800
1934
  const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
1801
1935
  const defs = new Defs(opts.idPrefix ?? "");
1802
1936
  const body = [];
1803
1937
  const fillProp = doc.props.fill;
1804
1938
  let bgFill = resolveProp(fillProp, ctx, "automatic");
1805
- const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark");
1939
+ const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark" && forPlatform(s));
1806
1940
  let darken = 0;
1807
1941
  if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
1808
1942
  let bgPaint;
1809
- const P = mask === "circle" ? 32 : 0;
1943
+ const P = mask === "circle" || mask === "none" && ctx.idiom === "watchOS" ? 32 : 0;
1810
1944
  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);
1945
+ const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" && forPlatform(s));
1812
1946
  if (monoOverride) {
1813
- bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
1947
+ const mono = resolveProp(fillProp, { appearance: dark ? "tinted-dark" : "tinted-light", idiom: ctx.idiom }, "automatic");
1948
+ bgPaint = paintFor(mono ?? "automatic", defs, full, dark);
1814
1949
  } else if (isClear) {
1815
1950
  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
1951
  } else if (isTinted) {
@@ -1851,7 +1986,14 @@ function renderSvg(doc, opts = {}) {
1851
1986
  plainId = plain;
1852
1987
  }
1853
1988
  function drawGroup(g, backdropId) {
1854
- const gpos = resolveProp(g.props.position, ctx, null);
1989
+ const gp = safePosition(resolveProp(g.props.position, ctx, null));
1990
+ const gs = gp?.scale ?? 1;
1991
+ const gtx = gp?.tx ?? 0;
1992
+ const gty = gp?.ty ?? 0;
1993
+ const gx0 = H2 + (-PAD - H2 - gtx) / gs;
1994
+ const gy0 = H2 + (-PAD - H2 - gty) / gs;
1995
+ const gw = (C2 + 2 * PAD) / gs;
1996
+ const localRegion = `x="${n(gx0)}" y="${n(gy0)}" width="${n(gw)}" height="${n(gw)}"`;
1855
1997
  const layers = FRONT_FIRST ? [...g.layers].reverse() : [...g.layers];
1856
1998
  const placed = [];
1857
1999
  for (const l of layers) {
@@ -1859,14 +2001,23 @@ function renderSvg(doc, opts = {}) {
1859
2001
  const name = resolveProp(l.props["image-name"], ctx, "");
1860
2002
  const asset = doc.assets[name];
1861
2003
  if (!asset || asset.kind === "pdf" || asset.kind === "other") continue;
1862
- const pos = resolveProp(l.props.position, ctx, null);
2004
+ if (!(asset.width > 0 && asset.height > 0 && Number.isFinite(asset.width) && Number.isFinite(asset.height))) continue;
2005
+ const pos = safePosition(resolveProp(l.props.position, ctx, null));
1863
2006
  const s = pos?.scale ?? 1;
1864
- const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
1865
2007
  const w = asset.width * s;
1866
2008
  const h = asset.height * s;
1867
- placed.push({ layer: l, asset, x: H2 + tx - w / 2, y: H2 + ty - h / 2, w, h });
2009
+ const x = H2 + (pos?.tx ?? 0) - w / 2;
2010
+ const y = H2 + (pos?.ty ?? 0) - h / 2;
2011
+ const cx0 = H2 + gtx + (x - H2) * gs;
2012
+ const cy0 = H2 + gty + (y - H2) * gs;
2013
+ const cx1 = cx0 + w * gs;
2014
+ const cy1 = cy0 + h * gs;
2015
+ if (cx1 < -P || cy1 < -P || cx0 > C2 + P || cy0 > C2 + P) continue;
2016
+ placed.push({ layer: l, asset, x, y, w, h });
1868
2017
  }
1869
2018
  if (!placed.length) return null;
2019
+ const groupOpacity = Math.min(1, Math.max(0, num2(resolveProp(g.props.opacity, ctx, 1), 1)));
2020
+ if (groupOpacity <= 0) return null;
1870
2021
  const imgId = /* @__PURE__ */ new Map();
1871
2022
  const layerEls = [];
1872
2023
  const glassEls = [];
@@ -1883,7 +2034,7 @@ function renderSvg(doc, opts = {}) {
1883
2034
  const s = p.w / p.asset.width;
1884
2035
  const place = `transform="translate(${n(p.x)} ${n(p.y)}) scale(${n(s)})"`;
1885
2036
  const fill = resolveProp(p.layer.props.fill, ctx, null);
1886
- const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
2037
+ const opacity = Math.min(1, Math.max(0, num2(resolveProp(p.layer.props.opacity, ctx, 1), 1)));
1887
2038
  const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
1888
2039
  const cb = contentBoxOf(p.asset);
1889
2040
  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 +2042,7 @@ function renderSvg(doc, opts = {}) {
1891
2042
  let el;
1892
2043
  if (paint) {
1893
2044
  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>`);
2045
+ defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" ${localRegion}><use href="#${iid}" ${place}/></mask>`);
1895
2046
  el = `<rect x="${n(p.x)}" y="${n(p.y)}" width="${n(p.w)}" height="${n(p.h)}" fill="${paint}" mask="url(#${mid})"/>`;
1896
2047
  } else {
1897
2048
  el = `<use href="#${iid}" ${place}/>`;
@@ -1903,43 +2054,53 @@ function renderSvg(doc, opts = {}) {
1903
2054
  glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
1904
2055
  }
1905
2056
  }
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})"` : "";
2057
+ const gTransform = gp ? ` transform="translate(${n(H2 + gtx)} ${n(H2 + gty)}) scale(${n(gs)}) translate(${-H2} ${-H2})"` : "";
2058
+ const canvasRegion = `filterUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"`;
2059
+ const groupRegion = `filterUnits="userSpaceOnUse" ${localRegion}`;
1907
2060
  const contentId = defs.id("gc");
1908
2061
  defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
1909
2062
  contentIds.push(contentId);
1910
2063
  const out = [];
1911
- const shadow = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
2064
+ const shadow0 = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
2065
+ const shadow = shadow0 && typeof shadow0 === "object" ? { kind: shadow0.kind, opacity: Math.min(1, Math.max(0, num2(shadow0.opacity, 0.5))) } : null;
1912
2066
  if (effects && shadow && shadow.kind !== "none" && shadow.opacity > 0) {
1913
2067
  const fid = defs.id("sh");
1914
2068
  if (shadow.kind === "neutral") {
1915
2069
  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>`
2070
+ `<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
2071
  );
1918
2072
  } else {
1919
2073
  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>`
2074
+ `<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
2075
  );
1922
2076
  }
1923
2077
  out.push(`<use href="#${contentId}" filter="url(#${fid})" opacity="${n(shadow.opacity)}"/>`);
1924
2078
  }
1925
- const tr = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
1926
- const blurMat = resolveProp(g.props["blur-material"], ctx, null);
2079
+ const tr0 = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
2080
+ const tr = tr0 && typeof tr0 === "object" ? { enabled: !!tr0.enabled, value: num2(tr0.value, 0.5) } : null;
2081
+ const blurRaw = resolveProp(g.props["blur-material"], ctx, void 0);
2082
+ const blurMat = blurRaw === null ? null : num2(blurRaw, 0.2);
1927
2083
  const translucent = effects && !!tr?.enabled && tr.value > 0;
1928
- const refr = resolveProp(g.props.refractivity, ctx, null);
2084
+ const refr0 = resolveProp(g.props.refractivity, ctx, null);
2085
+ const refr = refr0 && typeof refr0 === "object" ? { enabled: !!refr0.enabled, strength: num2(refr0.strength, 0), depth: num2(refr0.depth, 0) } : null;
1929
2086
  const refracts = effects && !!refr?.enabled && glassEls.length > 0;
1930
2087
  if (translucent || refracts) {
1931
2088
  const maskSrc = translucent ? `<use href="#${contentId}"/>` : `<g${gTransform}>${glassEls.join("")}</g>`;
1932
2089
  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);
2090
+ 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>`);
2091
+ const blurPx = Math.max(0, (blurMat === null ? 0 : 4 + blurMat * 40) + (refracts ? refr.depth * 10 : 0));
2092
+ let filterAttr = "";
2093
+ if (blurPx > 0) {
2094
+ const fid = defs.id("bk");
2095
+ defs.add(`<filter id="${fid}" ${canvasRegion}><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
2096
+ filterAttr = ` filter="url(#${fid})"`;
2097
+ }
2098
+ const zoom = 1.02 + (refracts ? refr.strength * 0.12 : 0.015);
1938
2099
  out.push(
1939
- `<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
2100
+ `<g mask="url(#${mid})"><use href="#${backdropId}"${filterAttr} transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
1940
2101
  );
1941
2102
  }
1942
- const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
2103
+ const gOpacity = refracts && !translucent ? 1 - Math.min(1, Math.max(0, refr.strength)) * 0.3 : 1;
1943
2104
  let fadeMask = "";
1944
2105
  if (translucent) {
1945
2106
  const v = Math.min(1, Math.max(0, tr.value));
@@ -1965,15 +2126,15 @@ function renderSvg(doc, opts = {}) {
1965
2126
  const fid = defs.id("sp");
1966
2127
  if (specular === "outside") {
1967
2128
  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>`
2129
+ `<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
2130
  );
1970
2131
  } 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>`
2132
+ `<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
2133
  );
1973
2134
  const glass = individual ? glassEls.map((e) => `<g filter="url(#${fid})">${e}</g>`).join("") : `<g filter="url(#${fid})">${glassEls.join("")}</g>`;
1974
2135
  out.push(`<g${gTransform}>${glass}</g>`);
1975
2136
  }
1976
- return out.join("");
2137
+ return groupOpacity < 1 ? `<g opacity="${n(groupOpacity)}">${out.join("")}</g>` : out.join("");
1977
2138
  }
1978
2139
  let top = `<use href="#${stackId}"/>`;
1979
2140
  if ((isClear || isTinted) && contentIds.length) {
@@ -1995,18 +2156,19 @@ function renderSvg(doc, opts = {}) {
1995
2156
  }
1996
2157
  const mid = defs.id("fm");
1997
2158
  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>`
2159
+ `<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
2160
  );
2000
2161
  top = `<use href="#${bgStackId}"/><g filter="url(#${fid})"><use href="#${stackId}" mask="url(#${mid})"/></g>`;
2001
2162
  }
2002
2163
  const clipId = defs.id("clip");
2003
- const shape = maskPath(mask);
2164
+ const shape = maskPath(mask, P);
2004
2165
  defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
2005
- const M = Math.max(0, opts.inset ?? 0);
2166
+ const inset = Math.min(C2 / 2 - 1, Math.max(0, num2(opts.inset, 0)));
2167
+ const M = inset > 0 ? inset * C2 / (C2 - 2 * inset) : 0;
2006
2168
  if (M > 0) {
2007
2169
  const sid = defs.id("ish");
2008
2170
  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>`
2171
+ `<filter id="${sid}" filterUnits="userSpaceOnUse" x="${-(M + 64)}" y="${-(M + 64)}" width="${C2 + 2 * (M + 64)}" height="${C2 + 2 * (M + 64)}"><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
2172
  );
2011
2173
  body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
2012
2174
  } else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
@@ -2021,8 +2183,9 @@ function renderSvg(doc, opts = {}) {
2021
2183
  }
2022
2184
  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
2185
  }
2186
+ var KNOWN_BLEND = new Set(BLEND_MODES);
2024
2187
  function cssBlend(mode) {
2025
- if (!mode || mode === "normal") return null;
2188
+ if (typeof mode !== "string" || !KNOWN_BLEND.has(mode) || mode === "normal") return null;
2026
2189
  if (mode === "plus-lighter") return "screen";
2027
2190
  if (mode === "plus-darker") return "multiply";
2028
2191
  return mode;
@@ -2065,6 +2228,7 @@ async function resvg() {
2065
2228
  }
2066
2229
  }
2067
2230
  async function svgToPng(svg, width) {
2231
+ if (!(width >= 2)) throw new Error(`Image size must be at least 2 px (got ${width})`);
2068
2232
  const { Resvg } = await resvg();
2069
2233
  const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
2070
2234
  return new Uint8Array(r.render().asPng());
@@ -2112,6 +2276,7 @@ var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.arr
2112
2276
  function describeProps(props) {
2113
2277
  const base = {};
2114
2278
  const overrides = {};
2279
+ const variants = /* @__PURE__ */ new Map();
2115
2280
  for (const [k, p] of Object.entries(props)) {
2116
2281
  const b = p.specs?.find((s) => s.appearance === void 0 && s.idiom === void 0);
2117
2282
  if (b) base[k] = b.value;
@@ -2120,21 +2285,32 @@ function describeProps(props) {
2120
2285
  const s = p.specs.find((e) => e.appearance === a && e.idiom === void 0);
2121
2286
  (overrides[a] ??= {})[k] = s.value;
2122
2287
  }
2288
+ for (const s of p.specs ?? []) {
2289
+ if (s.idiom === void 0) continue;
2290
+ const key = `${s.appearance ?? ""}|${s.idiom}`;
2291
+ if (!variants.has(key)) variants.set(key, { ...s.appearance ? { appearance: s.appearance } : {}, platform: s.idiom, values: {} });
2292
+ variants.get(key).values[k] = s.value;
2293
+ }
2123
2294
  }
2124
- return Object.keys(overrides).length ? { ...base, overrides } : base;
2295
+ return {
2296
+ ...base,
2297
+ ...Object.keys(overrides).length ? { overrides } : {},
2298
+ ...variants.size ? { variants: [...variants.values()] } : {}
2299
+ };
2125
2300
  }
2126
2301
  function inspect(doc) {
2127
2302
  return {
2128
2303
  order: "groups and layers are listed front to back (index 0 = frontmost)",
2129
2304
  document: describeProps(doc.props),
2305
+ // attributes first, so an unknown key in the file (e.g. "target") can't replace these fields
2130
2306
  groups: doc.groups.map((g, gi) => ({
2307
+ ...describeProps(g.props),
2131
2308
  target: nameOf(g) || `#${gi}`,
2132
2309
  index: gi,
2133
- ...describeProps(g.props),
2134
2310
  layers: g.layers.map((l, li) => ({
2311
+ ...describeProps(l.props),
2135
2312
  target: `${nameOf(g) || `#${gi}`}/${nameOf(l) || `#${li}`}`,
2136
- index: li,
2137
- ...describeProps(l.props)
2313
+ index: li
2138
2314
  }))
2139
2315
  })),
2140
2316
  assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
@@ -2152,9 +2328,7 @@ function targetLabel(doc, ref) {
2152
2328
  }
2153
2329
  async function saveFile(ws, out, bytes) {
2154
2330
  const p = await ws.resolve(out);
2155
- await mkdir2(dirname2(p), { recursive: true });
2156
- await writeFile2(p, bytes);
2157
- return p;
2331
+ return (await writeFiles(dirname2(p), { [basename(p)]: bytes }))[0];
2158
2332
  }
2159
2333
  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
2334
  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 +2353,9 @@ var TOOLS = [
2179
2353
  },
2180
2354
  output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
2181
2355
  mutates_input: true,
2356
+ writes_files: true,
2182
2357
  idempotency_hint: "not-idempotent",
2358
+ destructive: true,
2183
2359
  supports_dry_run: true,
2184
2360
  verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
2185
2361
  requires_visual_verification: false,
@@ -2187,14 +2363,16 @@ var TOOLS = [
2187
2363
  async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
2188
2364
  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
2365
  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
2366
  const doc = createDocument({
2193
2367
  ...background ? { background } : {},
2194
2368
  platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
2195
2369
  });
2196
2370
  if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
2197
- if (!dry_run) await ws.withLock(full, () => saveIcon(full, doc));
2371
+ await ws.withLock(full, async () => {
2372
+ const exists = await stat2(full).then(() => true, () => false);
2373
+ if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
2374
+ if (!dry_run) await saveIcon(full, doc);
2375
+ });
2198
2376
  return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
2199
2377
  }
2200
2378
  }),
@@ -2210,6 +2388,7 @@ var TOOLS = [
2210
2388
  input: { path: pathArg },
2211
2389
  output: inspectOutput,
2212
2390
  mutates_input: false,
2391
+ writes_files: false,
2213
2392
  idempotency_hint: "idempotent",
2214
2393
  supports_dry_run: false,
2215
2394
  verification: [],
@@ -2238,6 +2417,7 @@ var TOOLS = [
2238
2417
  },
2239
2418
  output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
2240
2419
  mutates_input: true,
2420
+ writes_files: false,
2241
2421
  idempotency_hint: "not-idempotent",
2242
2422
  supports_dry_run: true,
2243
2423
  verification: ["icon_inspect lists the new group"],
@@ -2280,6 +2460,7 @@ var TOOLS = [
2280
2460
  },
2281
2461
  output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
2282
2462
  mutates_input: true,
2463
+ writes_files: false,
2283
2464
  idempotency_hint: "not-idempotent",
2284
2465
  supports_dry_run: true,
2285
2466
  verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
@@ -2355,6 +2536,7 @@ var TOOLS = [
2355
2536
  },
2356
2537
  output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
2357
2538
  mutates_input: true,
2539
+ writes_files: false,
2358
2540
  idempotency_hint: "idempotent",
2359
2541
  supports_dry_run: true,
2360
2542
  verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
@@ -2390,6 +2572,7 @@ var TOOLS = [
2390
2572
  input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
2391
2573
  output: loose({ groups: groupsOutline }),
2392
2574
  mutates_input: true,
2575
+ writes_files: false,
2393
2576
  idempotency_hint: "idempotent",
2394
2577
  supports_dry_run: true,
2395
2578
  verification: ["`groups` lists the new order (front to back)"],
@@ -2403,6 +2586,7 @@ var TOOLS = [
2403
2586
  const ref = resolvePath(doc, target);
2404
2587
  let gid;
2405
2588
  if (to_group) {
2589
+ if (ref.kind !== "layer") throw new Error("to_group only applies to layers; groups are reordered with index");
2406
2590
  const g = resolvePath(doc, to_group);
2407
2591
  if (g.kind !== "group") throw new Error("to_group must be a group");
2408
2592
  gid = g.id;
@@ -2434,6 +2618,7 @@ var TOOLS = [
2434
2618
  },
2435
2619
  output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
2436
2620
  mutates_input: true,
2621
+ writes_files: false,
2437
2622
  idempotency_hint: "idempotent",
2438
2623
  supports_dry_run: true,
2439
2624
  verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
@@ -2469,6 +2654,7 @@ var TOOLS = [
2469
2654
  input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
2470
2655
  output: loose({ removed: z2.string(), groups: groupsOutline }),
2471
2656
  mutates_input: true,
2657
+ writes_files: false,
2472
2658
  idempotency_hint: "not-idempotent",
2473
2659
  destructive: true,
2474
2660
  supports_dry_run: true,
@@ -2505,6 +2691,7 @@ var TOOLS = [
2505
2691
  issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
2506
2692
  }),
2507
2693
  mutates_input: false,
2694
+ writes_files: false,
2508
2695
  idempotency_hint: "idempotent",
2509
2696
  supports_dry_run: false,
2510
2697
  verification: ['No issue with severity "error"'],
@@ -2531,12 +2718,13 @@ var TOOLS = [
2531
2718
  platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
2532
2719
  size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
2533
2720
  mask: maskEnum.optional(),
2534
- tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2721
+ 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
2722
  light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
2536
2723
  out: z2.string().optional().describe("Save the PNG to this path")
2537
2724
  },
2538
2725
  output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
2539
2726
  mutates_input: false,
2727
+ writes_files: true,
2540
2728
  idempotency_hint: "idempotent",
2541
2729
  supports_dry_run: false,
2542
2730
  verification: ["Look at the returned image"],
@@ -2569,11 +2757,12 @@ var TOOLS = [
2569
2757
  input: {
2570
2758
  path: pathArg,
2571
2759
  cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
2572
- tint: z2.string().optional(),
2760
+ 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
2761
  out: z2.string().optional()
2574
2762
  },
2575
2763
  output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
2576
2764
  mutates_input: false,
2765
+ writes_files: true,
2577
2766
  idempotency_hint: "idempotent",
2578
2767
  supports_dry_run: false,
2579
2768
  verification: ["Look at the returned image: every appearance should stay legible"],
@@ -2599,12 +2788,14 @@ var TOOLS = [
2599
2788
  path: pathArg,
2600
2789
  out_dir: z2.string(),
2601
2790
  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")
2791
+ sizes: z2.array(z2.number().int().min(16).max(2048)).min(1).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2792
+ appearances: z2.array(appearanceEnum).min(1).optional().describe("PNG appearances, default all six")
2604
2793
  },
2605
2794
  output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
2606
2795
  mutates_input: false,
2796
+ writes_files: true,
2607
2797
  idempotency_hint: "idempotent",
2798
+ destructive: true,
2608
2799
  supports_dry_run: false,
2609
2800
  verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
2610
2801
  requires_visual_verification: false,
@@ -2612,6 +2803,9 @@ var TOOLS = [
2612
2803
  async run(ws, { path, out_dir, formats, sizes, appearances }) {
2613
2804
  const full = await ws.iconPath(path, { mustExist: true });
2614
2805
  const outDir = await ws.resolve(out_dir);
2806
+ const inside = relative2(full, outDir);
2807
+ if (inside === "" || !inside.startsWith(`..${sep2}`) && inside !== ".." && !isAbsolute2(inside))
2808
+ throw new Error("out_dir is inside the icon bundle; choose a folder outside it");
2615
2809
  const doc = await ws.read(full);
2616
2810
  const name = basename(full).replace(/\.icon(\.zip)?$/, "");
2617
2811
  const raster = nodeRasterizer(doc);
@@ -2620,16 +2814,9 @@ var TOOLS = [
2620
2814
  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
2815
  const written = [];
2622
2816
  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);
2817
+ written.push(...await saveIcon(join2(outDir, `${name}.icon`), doc));
2632
2818
  }
2819
+ if (formats.includes("icns")) written.push(...await writeFiles(outDir, { [`${name}.icns`]: await buildIcns(raster) }));
2633
2820
  if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
2634
2821
  if (formats.includes("png"))
2635
2822
  written.push(
@@ -2646,7 +2833,7 @@ async function runTool(ws, t, args) {
2646
2833
  }
2647
2834
 
2648
2835
  // src/version.ts
2649
- var VERSION = "0.2.0";
2836
+ var VERSION = "0.2.2";
2650
2837
 
2651
2838
  // src/contract.ts
2652
2839
  var RESOURCES = [
@@ -2683,6 +2870,7 @@ function toolSpec(t) {
2683
2870
  input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
2684
2871
  output_schema: jsonSchema(t.output, "output"),
2685
2872
  mutates_input: t.mutates_input,
2873
+ writes_files: t.writes_files,
2686
2874
  idempotency_hint: t.idempotency_hint,
2687
2875
  destructive: !!t.destructive,
2688
2876
  supports_dry_run: t.supports_dry_run,
@@ -2715,7 +2903,7 @@ function buildContract() {
2715
2903
  rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
2716
2904
  },
2717
2905
  tools: TOOLS.map(toolSpec),
2718
- capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a.localeCompare(b))),
2906
+ capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
2719
2907
  resources: RESOURCES,
2720
2908
  prompts: PROMPTS,
2721
2909
  icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
@@ -2809,10 +2997,11 @@ function createServer(ws) {
2809
2997
  {
2810
2998
  title: t.title,
2811
2999
  description: t.description,
2812
- inputSchema: t.input,
3000
+ // strict: a misspelt argument (dryRun for dry_run) is an error, not silently dropped
3001
+ inputSchema: z4.object(t.input).strict(),
2813
3002
  outputSchema: t.output,
2814
3003
  annotations: {
2815
- readOnlyHint: !t.mutates_input,
3004
+ readOnlyHint: !t.mutates_input && !t.writes_files,
2816
3005
  destructiveHint: !!t.destructive,
2817
3006
  idempotentHint: t.idempotency_hint === "idempotent",
2818
3007
  openWorldHint: false
@@ -2886,8 +3075,8 @@ Use the fresnel tools:
2886
3075
  }
2887
3076
 
2888
3077
  // 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";
3078
+ import { lstat as lstat2, realpath, stat as stat3 } from "fs/promises";
3079
+ import { dirname as dirname3, isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
2891
3080
  var Workspace = class {
2892
3081
  roots;
2893
3082
  locks = /* @__PURE__ */ new Map();
@@ -2900,13 +3089,19 @@ var Workspace = class {
2900
3089
  /** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
2901
3090
  async resolve(p, opts = {}) {
2902
3091
  if (!p || typeof p !== "string") throw new Error("A path is required");
2903
- const abs = isAbsolute2(p) ? resolve2(p) : resolve2(this.roots[0], p);
3092
+ const abs = isAbsolute3(p) ? resolve2(p) : resolve2(this.roots[0], p);
2904
3093
  let probe = abs;
2905
3094
  let suffix = "";
2906
3095
  for (; ; ) {
2907
3096
  try {
2908
3097
  const real = await realpath(probe);
2909
3098
  const full = suffix ? resolve2(real, suffix) : real;
3099
+ if (suffix) {
3100
+ const first = resolve2(real, suffix.split(sep3)[0]);
3101
+ const st = await lstat2(first).catch(() => null);
3102
+ if (st?.isSymbolicLink()) throw new Error(`Access denied: ${p} goes through a symbolic link that points nowhere`);
3103
+ if (st) throw new Error(`Invalid path: ${p} (${suffix.split(sep3)[0]} is not a folder)`);
3104
+ }
2910
3105
  if (!await this.allowed(full)) throw new Error(`Access denied: ${p} is outside the allowed roots (${this.roots.join(", ")})`);
2911
3106
  if (opts.mustExist && full !== real) throw new Error(`Not found: ${p}`);
2912
3107
  return full;
@@ -2915,7 +3110,7 @@ var Workspace = class {
2915
3110
  if (opts.mustExist) throw new Error(`Not found: ${p}`);
2916
3111
  const parent = dirname3(probe);
2917
3112
  if (parent === probe) throw new Error(`Invalid path: ${p}`);
2918
- suffix = suffix ? `${probe.slice(parent.length + 1)}${sep2}${suffix}` : probe.slice(parent.length + 1);
3113
+ suffix = suffix ? `${probe.slice(parent.length + 1)}${sep3}${suffix}` : probe.slice(parent.length + 1);
2919
3114
  probe = parent;
2920
3115
  }
2921
3116
  }
@@ -2928,15 +3123,17 @@ var Workspace = class {
2928
3123
  root = await realpath(r);
2929
3124
  } catch {
2930
3125
  }
2931
- const rel = relative2(root, full);
2932
- if (rel === "" || !rel.startsWith("..") && !isAbsolute2(rel)) return true;
3126
+ const rel = relative3(root, full);
3127
+ if (rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute3(rel)) return true;
2933
3128
  }
2934
3129
  return false;
2935
3130
  }
2936
3131
  /** Normalizes an icon path: accepts "Foo", "Foo.icon", ".../Foo.icon/icon.json". */
2937
3132
  async iconPath(p, opts = {}) {
2938
- let q = p.trim();
3133
+ let q = (typeof p === "string" ? p : "").trim().replace(/[\\/]+$/, "");
2939
3134
  if (q.endsWith("/icon.json") || q.endsWith("\\icon.json")) q = q.slice(0, -"/icon.json".length);
3135
+ const base = q.split(/[\\/]/).pop() ?? "";
3136
+ if (!base || base === ".icon" || base === ".icon.zip" || base === "." || base === "..") throw new Error(`Invalid icon path "${p}": give a name such as "MyApp.icon"`);
2940
3137
  if (!q.endsWith(".icon") && !q.endsWith(".icon.zip")) q = `${q}.icon`;
2941
3138
  const full = await this.resolve(q, opts);
2942
3139
  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.2",
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.2",
42
+ "@fresnel/render": "0.2.2"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsup",