fresnel-icon 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/cli.js +362 -154
  2. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -18,8 +18,12 @@ var APPEARANCE_LABEL = {
18
18
  function appearanceKeys(a) {
19
19
  switch (a) {
20
20
  case "dark":
21
- case "clear-dark":
22
21
  return ["dark"];
22
+ // Clear and Tinted (light and dark) are all rendered from Icon Composer's Mono, written as "tinted".
23
+ // Apple: "For mono, you can preview clear and tinted variants"; Assets.car keeps only light / dark /
24
+ // tintable renditions. A dark value does not carry over to Clear dark [推測: strong].
25
+ case "clear-light":
26
+ case "clear-dark":
23
27
  case "tinted-light":
24
28
  case "tinted-dark":
25
29
  return ["tinted"];
@@ -135,6 +139,7 @@ function readProps(obj) {
135
139
  function writeProps(props, order, structural = {}) {
136
140
  const out = {};
137
141
  const emitted = /* @__PURE__ */ new Set();
142
+ let fromFile = true;
138
143
  const emit = (rawKey) => {
139
144
  if (emitted.has(rawKey)) return;
140
145
  if (rawKey in structural) {
@@ -142,17 +147,28 @@ function writeProps(props, order, structural = {}) {
142
147
  emitted.add(rawKey);
143
148
  return;
144
149
  }
150
+ const literal = props[rawKey];
151
+ if (rawKey.endsWith(SPEC_SUFFIX) && literal?.plain) {
152
+ out[rawKey] = structuredClone(literal.plain.value);
153
+ emitted.add(rawKey);
154
+ return;
155
+ }
145
156
  const isSpec = rawKey.endsWith(SPEC_SUFFIX);
146
157
  const base = isSpec ? rawKey.slice(0, -SPEC_SUFFIX.length) : rawKey;
147
158
  const p = props[base];
148
159
  if (!p) return;
149
- if (isSpec ? p.specs && p.specs.length > 0 : p.plain) {
160
+ if (isSpec ? p.specs && (p.specs.length > 0 || fromFile) : p.plain) {
150
161
  out[rawKey] = structuredClone(isSpec ? p.specs : p.plain.value);
151
162
  emitted.add(rawKey);
152
163
  }
153
164
  };
154
165
  for (const k of order) emit(k);
166
+ fromFile = false;
155
167
  for (const [base, p] of Object.entries(props)) {
168
+ if (base.endsWith(SPEC_SUFFIX) && p.plain) {
169
+ emit(base);
170
+ continue;
171
+ }
156
172
  if (p.plain) emit(base);
157
173
  if (p.specs?.length) emit(base + SPEC_SUFFIX);
158
174
  }
@@ -200,6 +216,11 @@ function baseValue(p) {
200
216
  if (b) return b.value;
201
217
  return p.plain?.value;
202
218
  }
219
+ function variantValue(p, v) {
220
+ const vv = toVariant(v);
221
+ if (isEmpty(vv)) return baseValue(p);
222
+ return p?.specs?.find((e) => sameVariant(e, vv))?.value;
223
+ }
203
224
  function variantsOf(p) {
204
225
  return (p?.specs ?? []).filter((e) => !isBaseEntry(e)).map((e) => ({ ...e.appearance ? { appearance: e.appearance } : {}, ...e.idiom ? { idiom: e.idiom } : {} }));
205
226
  }
@@ -392,6 +413,7 @@ function svgSize(svg) {
392
413
  return { width: 1024, height: 1024 };
393
414
  }
394
415
  function pngSize(b) {
416
+ if (b.length < 24) return { width: 0, height: 0 };
395
417
  const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
396
418
  return { width: dv.getUint32(16), height: dv.getUint32(20) };
397
419
  }
@@ -403,6 +425,10 @@ function jpgSize(b) {
403
425
  continue;
404
426
  }
405
427
  const marker = b[i + 1];
428
+ if (marker === 255) {
429
+ i++;
430
+ continue;
431
+ }
406
432
  const len = b[i + 2] << 8 | b[i + 3];
407
433
  if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
408
434
  return { height: b[i + 5] << 8 | b[i + 6], width: b[i + 7] << 8 | b[i + 8] };
@@ -442,15 +468,26 @@ function expandInternalEntities(svg) {
442
468
  if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
443
469
  }
444
470
  const body = svg.slice(0, doctype.index) + svg.slice(doctype.index + doctype[0].length);
445
- return body.replace(/&([\w.-]+);/g, (m, name) => ents.get(name) ?? m);
471
+ let budget = Math.max(1 << 20, body.length * 4);
472
+ return body.replace(/&([\w.-]+);/g, (m, name) => {
473
+ const v = ents.get(name);
474
+ if (v === void 0 || v.length > budget) return m;
475
+ budget -= v.length;
476
+ return v;
477
+ });
446
478
  }
479
+ var DATA_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp|svg\+xml)(?:;[\w.+-]+=[\w.+-]+)*;base64,[a-z0-9+/=\s]*$/i;
447
480
  function safeUrlRef(v) {
448
481
  const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
449
- return t.startsWith("#") || /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]*$/i.test(t);
482
+ return t.startsWith("#") || DATA_IMAGE.test(t);
450
483
  }
484
+ function decodeXmlEntities(v) {
485
+ 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]);
486
+ }
487
+ var escapeXmlText = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
451
488
  function cleanCss(css0) {
452
489
  const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
453
- return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
490
+ 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, "");
454
491
  }
455
492
  function cleanAttrs(raw) {
456
493
  const out = [];
@@ -471,8 +508,10 @@ function cleanAttrs(raw) {
471
508
  if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
472
509
  let v = value;
473
510
  if (lname === "style" || /url\(/.test(plain)) {
474
- v = cleanCss(v);
475
- if (/url\((?!['"]?#)/.test(decodeForCheck(v).replace(/url\(['"]?data:image\/(png|jpe?g|gif|webp);base64,/g, "url(#"))) continue;
511
+ const css = cleanCss(decodeXmlEntities(v));
512
+ const refs = [...decodeForCheck(css).matchAll(/url\(([^)]*)\)/g)].map((m2) => m2[1]);
513
+ if (refs.some((r) => !safeUrlRef(r))) continue;
514
+ v = escapeXmlText(css);
476
515
  }
477
516
  out.push(`${name}="${v.replace(/"/g, "&quot;")}"`);
478
517
  }
@@ -486,7 +525,7 @@ function sanitizeOnce(svg) {
486
525
  while (i < svg.length) {
487
526
  const lt = svg.indexOf("<", i);
488
527
  const text2 = lt < 0 ? svg.slice(i) : svg.slice(i, lt);
489
- if (!skip.length) out += inStyle ? cleanCss(text2) : text2;
528
+ if (!skip.length) out += inStyle ? escapeXmlText(cleanCss(decodeXmlEntities(text2))) : text2;
490
529
  if (lt < 0) break;
491
530
  if (svg.startsWith("<!--", lt)) {
492
531
  const e = svg.indexOf("-->", lt + 4);
@@ -496,7 +535,7 @@ function sanitizeOnce(svg) {
496
535
  if (svg.startsWith("<![CDATA[", lt)) {
497
536
  const e = svg.indexOf("]]>", lt);
498
537
  const body = svg.slice(lt + 9, e < 0 ? svg.length : e);
499
- if (!skip.length) out += inStyle ? `<![CDATA[${cleanCss(body)}]]>` : `<![CDATA[${body}]]>`;
538
+ if (!skip.length) out += inStyle ? escapeXmlText(cleanCss(body)) : `<![CDATA[${body}]]>`;
500
539
  i = e < 0 ? svg.length : e + 3;
501
540
  continue;
502
541
  }
@@ -569,12 +608,12 @@ function sanitizeOnce(svg) {
569
608
  }
570
609
  function sanitizeSvg(svg) {
571
610
  let s = expandInternalEntities(svg);
572
- for (let n2 = 0; n2 < 5; n2++) {
611
+ for (let n2 = 0; n2 < 8; n2++) {
573
612
  const next = sanitizeOnce(s);
574
- if (next === s) break;
613
+ if (next === s) return s;
575
614
  s = next;
576
615
  }
577
- return s;
616
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"/>';
578
617
  }
579
618
  function svgFacts(svg) {
580
619
  const widths = [...svg.matchAll(/stroke-width\s*[:=]\s*["']?\s*([0-9.]+)/gi)].map((m) => Number(m[1])).filter((n2) => n2 > 0);
@@ -601,7 +640,15 @@ function assetText(a) {
601
640
  function mimeOf(a) {
602
641
  return a.kind === "svg" ? "image/svg+xml" : a.kind === "png" ? "image/png" : a.kind === "jpg" ? "image/jpeg" : "application/octet-stream";
603
642
  }
643
+ var MAX_DECODE_PIXELS = 64e6;
604
644
  function decodePng(b) {
645
+ try {
646
+ return decodePngUnchecked(b);
647
+ } catch {
648
+ return null;
649
+ }
650
+ }
651
+ function decodePngUnchecked(b) {
605
652
  const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
606
653
  let off = 8;
607
654
  let width = 0;
@@ -629,6 +676,7 @@ function decodePng(b) {
629
676
  off += 12 + len;
630
677
  }
631
678
  if (depth !== 8 || interlace !== 0) return null;
679
+ if (!width || !height || width * height > MAX_DECODE_PIXELS) return null;
632
680
  const ch = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ctype];
633
681
  if (!ch) return null;
634
682
  const total = idat.reduce((a, x) => a + x.length, 0);
@@ -806,7 +854,7 @@ function isSafeBundlePath(rel) {
806
854
  return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
807
855
  }
808
856
  function readBundle(files) {
809
- const jsonKey = Object.keys(files).find((k) => k === "icon.json" || k.endsWith("/icon.json"));
857
+ 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];
810
858
  if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
811
859
  const prefix = jsonKey.slice(0, -"icon.json".length);
812
860
  const base = parseIconJson(strFromU8(files[jsonKey]));
@@ -815,7 +863,7 @@ function readBundle(files) {
815
863
  for (const [k, v] of Object.entries(files)) {
816
864
  if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
817
865
  const rel = k.slice(prefix.length);
818
- if (!isSafeBundlePath(rel)) continue;
866
+ if (!isSafeBundlePath(rel) || rel.endsWith(".fresnel-tmp")) continue;
819
867
  if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
820
868
  const name = rel.slice("Assets/".length);
821
869
  if (name) assets[name] = makeAsset(name, v);
@@ -850,10 +898,12 @@ function syncFeatures(doc) {
850
898
  }
851
899
  function writeBundle(doc, extra = {}) {
852
900
  const synced = { props: { ...doc.props }, groups: doc.groups };
901
+ if (synced.props.features) synced.props.features = structuredClone(synced.props.features);
853
902
  syncFeatures(synced);
854
903
  const files = { ...extra };
855
- files["icon.json"] = strToU8(serializeIconJson({ ...doc, props: synced.props }));
856
904
  for (const a of Object.values(doc.assets)) files[`Assets/${a.name}`] = a.bytes;
905
+ delete files["icon.json"];
906
+ files["icon.json"] = strToU8(serializeIconJson({ ...doc, props: synced.props }));
857
907
  return files;
858
908
  }
859
909
  function unzipBundle(zip) {
@@ -902,8 +952,13 @@ function resolvePath(doc, path) {
902
952
  const p = path.trim();
903
953
  if (p === "" || p === "document" || p === "/") return { kind: "document" };
904
954
  const whole = doc.groups.filter((g2) => nameOf(g2).toLowerCase() === p.toLowerCase());
905
- if (whole.length === 1) return { kind: "group", id: whole[0].id };
906
955
  const [gp, lp, ...rest] = p.split("/");
956
+ if (whole.length === 1) {
957
+ const g2 = lp !== void 0 && !rest.length ? doc.groups.find((x) => nameOf(x).toLowerCase() === gp.trim().toLowerCase()) : void 0;
958
+ if (g2?.layers.some((l2) => nameOf(l2).toLowerCase() === lp.trim().toLowerCase()))
959
+ throw new OpError(`"${path}" matches both a group and a layer; use #index (e.g. "#${doc.groups.indexOf(whole[0])}")`);
960
+ return { kind: "group", id: whole[0].id };
961
+ }
907
962
  if (rest.length) throw new OpError(`Invalid target "${path}". Use "Group" or "Group/Layer".`);
908
963
  const pick = (list, key, what) => {
909
964
  const k = key.trim();
@@ -940,9 +995,10 @@ function cleanName(name) {
940
995
  }
941
996
  function uniqueName(existing, wanted0) {
942
997
  const wanted = cleanName(wanted0) || "Untitled";
943
- if (!existing.includes(wanted)) return wanted;
998
+ const taken = new Set(existing.map((n2) => n2.toLowerCase()));
999
+ if (!taken.has(wanted.toLowerCase())) return wanted;
944
1000
  let i = 2;
945
- while (existing.includes(`${wanted} ${i}`)) i++;
1001
+ while (taken.has(`${wanted} ${i}`.toLowerCase())) i++;
946
1002
  return `${wanted} ${i}`;
947
1003
  }
948
1004
  function addGroup(doc, opts = {}) {
@@ -963,10 +1019,15 @@ function clampIndex(i, len) {
963
1019
  function addAsset(doc, asset) {
964
1020
  let name = asset.name.replace(/[\\/\u0000-\u001f:]/g, "_").replace(/^[.\s]+/, "").trim() || "image";
965
1021
  const dot = name.lastIndexOf(".");
966
- const stem = dot > 0 ? name.slice(0, dot) : name;
967
- const ext = dot > 0 ? name.slice(dot) : "";
1022
+ const ext = dot > 0 ? name.slice(dot, dot + 16) : "";
1023
+ let stem = dot > 0 ? name.slice(0, dot) : name;
1024
+ while (new TextEncoder().encode(stem).length > 200) stem = stem.slice(0, -1);
1025
+ name = stem + ext;
1026
+ const clash = (n2) => Object.values(doc.assets).find((a) => a.name.toLowerCase() === n2.toLowerCase());
968
1027
  let i = 2;
969
- while (doc.assets[name] && !sameBytes(doc.assets[name].bytes, asset.bytes)) name = `${stem} ${i++}${ext}`;
1028
+ for (let c = clash(name); c && !sameBytes(c.bytes, asset.bytes); c = clash(name)) name = `${stem} ${i++}${ext}`;
1029
+ const same = clash(name);
1030
+ if (same) return same.name;
970
1031
  doc.assets[name] = { ...asset, name };
971
1032
  return name;
972
1033
  }
@@ -1018,31 +1079,40 @@ function normalizeFillColors(f) {
1018
1079
  const o = { ...f };
1019
1080
  if (typeof o.solid === "string") o.solid = normalizeColorInput(o.solid);
1020
1081
  if (typeof o["automatic-gradient"] === "string") o["automatic-gradient"] = normalizeColorInput(o["automatic-gradient"]);
1021
- if (Array.isArray(o["linear-gradient"])) o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
1082
+ if (Array.isArray(o["linear-gradient"])) {
1083
+ if (!o["linear-gradient"].every((c) => typeof c === "string")) throw new OpError("linear-gradient takes a list of color strings");
1084
+ o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
1085
+ }
1022
1086
  return o;
1023
1087
  }
1024
1088
  function removeNode(doc, ref) {
1025
1089
  if (ref.kind === "document") throw new OpError("The document can't be removed");
1090
+ let removedImages;
1026
1091
  if (ref.kind === "group") {
1027
1092
  const i = doc.groups.findIndex((g) => g.id === ref.id);
1028
1093
  if (i < 0) throw new OpError("Group not found");
1094
+ removedImages = imagesOf(doc.groups[i].layers);
1029
1095
  doc.groups.splice(i, 1);
1030
1096
  } else {
1031
1097
  const f = findLayer(doc, ref.id);
1032
1098
  if (!f) throw new OpError("Layer not found");
1099
+ removedImages = imagesOf([f.layer]);
1033
1100
  f.group.layers.splice(f.index, 1);
1034
1101
  }
1035
- pruneAssets(doc);
1102
+ pruneAssets(doc, removedImages);
1036
1103
  }
1037
- function pruneAssets(doc) {
1104
+ function imagesOf(layers) {
1038
1105
  const used = /* @__PURE__ */ new Set();
1039
- for (const g of doc.groups)
1040
- for (const l of g.layers) {
1041
- const p = l.props["image-name"];
1042
- if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
1043
- for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
1044
- }
1045
- for (const k of Object.keys(doc.assets)) if (!used.has(k)) delete doc.assets[k];
1106
+ for (const l of layers) {
1107
+ const p = l.props["image-name"];
1108
+ if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
1109
+ for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
1110
+ }
1111
+ return used;
1112
+ }
1113
+ function pruneAssets(doc, only) {
1114
+ const used = imagesOf(doc.groups.flatMap((g) => g.layers));
1115
+ for (const k of Object.keys(doc.assets)) if (!used.has(k) && (!only || only.has(k))) delete doc.assets[k];
1046
1116
  }
1047
1117
  function moveNode(doc, ref, index, toGroupId) {
1048
1118
  if (ref.kind === "document") throw new OpError("The document can't be moved");
@@ -1109,9 +1179,10 @@ function validate(doc) {
1109
1179
  let anyTinted = false;
1110
1180
  const scan = (props) => {
1111
1181
  for (const p of Object.values(props)) {
1112
- const o = overriddenAppearances(p);
1113
- if (o.includes("dark")) anyDark = true;
1114
- if (o.includes("tinted")) anyTinted = true;
1182
+ for (const v of variantsOf(p)) {
1183
+ if (v.appearance === "dark") anyDark = true;
1184
+ if (v.appearance === "tinted") anyTinted = true;
1185
+ }
1115
1186
  }
1116
1187
  };
1117
1188
  scan(doc.props);
@@ -1142,19 +1213,22 @@ function validate(doc) {
1142
1213
  }
1143
1214
  if (Math.abs(a.width - a.height) > 0.5) {
1144
1215
  push("V-03", `"${n2}" is ${round(a.width)}\xD7${round(a.height)}. Apple recommends square, unmasked layers.`, lName, lref);
1145
- } else if (a.kind !== "svg" && a.width * pos.scale < 1024) {
1146
- 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);
1216
+ } else if (a.kind !== "svg" && pos.scale > 1.01) {
1217
+ 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);
1147
1218
  }
1148
1219
  if (a.kind === "png" && a.width === a.height && a.width >= 256) {
1149
1220
  const bmp = safeDecode(a.bytes);
1150
1221
  if (bmp && looksPreMasked(bmp)) push("V-04", `"${n2}" seems to have rounded corners baked in. The system masks icons itself.`, lName, lref);
1151
1222
  }
1152
1223
  if (a.kind === "svg") {
1153
- const f = svgFacts(assetText(a));
1224
+ const text2 = assetText(a);
1225
+ const f = svgFacts(text2);
1226
+ const vb = /<svg\b[^>]*\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)/i.exec(text2);
1227
+ const unit2 = vb && Number(vb[1]) > 0 ? svgSize(text2).width / Number(vb[1]) : 1;
1154
1228
  if (f.hasText) push("V-07", `"${n2}" contains <text>. Convert text to outlines.`, lName, lref);
1155
1229
  if (f.hasFilter) push("V-07", `"${n2}" uses SVG filters (blur, glow, shadow). Let the system draw effects.`, lName, lref);
1156
- if (f.minStrokeWidth !== null && f.minStrokeWidth * pos.scale < 10)
1157
- push("V-06", `"${n2}" has strokes as thin as ${f.minStrokeWidth}. They'll be under 0.16 px at 16 px.`, lName, lref);
1230
+ if (f.minStrokeWidth !== null && f.minStrokeWidth * unit2 * pos.scale < 10)
1231
+ 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);
1158
1232
  }
1159
1233
  }
1160
1234
  }
@@ -1370,10 +1444,24 @@ function moveBy(doc, ref, dx, dy, ctx, variant) {
1370
1444
  props = f.layer.props;
1371
1445
  scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
1372
1446
  }
1373
- const p = resolveProp(props.position, ctx, { scale: 1, "translation-in-points": [0, 0] }) ?? { scale: 1, "translation-in-points": [0, 0] };
1374
- const [x, y] = p["translation-in-points"] ?? [0, 0];
1375
1447
  const round2 = (v) => Math.round(v * 100) / 100;
1376
- setProp(doc, ref, "position", { ...p, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] }, variant);
1448
+ const shift = (p) => {
1449
+ const q = p ?? { scale: 1, "translation-in-points": [0, 0] };
1450
+ const [x, y] = q["translation-in-points"] ?? [0, 0];
1451
+ return { ...q, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] };
1452
+ };
1453
+ const vv = toVariant(variant);
1454
+ if (vv.appearance !== void 0 || vv.idiom !== void 0) {
1455
+ const cur = variantValue(props.position, vv) ?? resolveProp(props.position, ctx, null);
1456
+ setProp(doc, ref, "position", shift(cur), variant);
1457
+ return;
1458
+ }
1459
+ const pos = props.position;
1460
+ setProp(doc, ref, "position", shift(baseValue(pos)), void 0);
1461
+ for (const e of pos?.specs ?? []) {
1462
+ if (e.appearance === void 0 && e.idiom === void 0) continue;
1463
+ if (e.value && typeof e.value === "object") e.value = shift(e.value);
1464
+ }
1377
1465
  }
1378
1466
  function alignNodes(doc, refs, how, ctx, opts = {}) {
1379
1467
  const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
@@ -1454,6 +1542,7 @@ async function saveIcon(path, doc) {
1454
1542
  const assetsDir = join(root, "Assets");
1455
1543
  await assertNoSymlink(root, assetsDir);
1456
1544
  await mkdir(assetsDir, { recursive: true });
1545
+ const written = [];
1457
1546
  for (const [rel, bytes] of Object.entries(files)) {
1458
1547
  const target = resolve(root, ...rel.split("/"));
1459
1548
  await assertNoSymlink(root, target);
@@ -1463,16 +1552,20 @@ async function saveIcon(path, doc) {
1463
1552
  await rm(tmp, { force: true });
1464
1553
  await writeFile(tmp, bytes, { flag: "wx" });
1465
1554
  await rename(tmp, target);
1555
+ written.push(target);
1466
1556
  }
1467
1557
  const keep = new Set(Object.keys(doc.assets));
1468
1558
  await assertNoSymlink(root, assetsDir);
1469
1559
  for (const e of await readdir(assetsDir, { withFileTypes: true })) {
1470
- if (e.isFile() && !keep.has(e.name) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1560
+ if (!e.isFile() || keep.has(e.name)) continue;
1561
+ if (e.name.endsWith(".fresnel-tmp") || isSafeBundlePath(`Assets/${e.name}`) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1471
1562
  }
1563
+ return written;
1472
1564
  }
1473
1565
  async function writeFiles(dir, files) {
1474
1566
  const written = [];
1475
1567
  const root = resolve(dir);
1568
+ if ((await lstat(root).catch(() => null))?.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${dir}`);
1476
1569
  for (const [rel, bytes] of Object.entries(files)) {
1477
1570
  const target = resolve(root, ...rel.split("/"));
1478
1571
  const r = relative(root, target);
@@ -1523,7 +1616,7 @@ function contentBoxOf(asset) {
1523
1616
  }
1524
1617
  function svgAssetsNeedingBounds(doc) {
1525
1618
  const out = /* @__PURE__ */ new Set();
1526
- const hasGradient = (p) => [p?.plain?.value, ...(p?.specs ?? []).map((s) => s.value)].some((v) => !!v && typeof v === "object" && "linear-gradient" in v);
1619
+ 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));
1527
1620
  for (const g of doc.groups)
1528
1621
  for (const l of g.layers) {
1529
1622
  if (!hasGradient(l.props.fill)) continue;
@@ -1552,12 +1645,12 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1552
1645
  }
1553
1646
  var SQUIRCLE = squirclePath();
1554
1647
  var C = CANVAS;
1555
- function maskPath(shape) {
1648
+ function maskPath(shape, pad2 = 0) {
1556
1649
  if (shape === "circle") {
1557
1650
  const r = C / 2 + 32;
1558
1651
  return `M${C / 2},${C / 2 - r} A${r},${r} 0 1 1 ${C / 2},${C / 2 + r} A${r},${r} 0 1 1 ${C / 2},${C / 2 - r}Z`;
1559
1652
  }
1560
- if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
1653
+ if (shape === "none") return `M${-pad2},${-pad2} H${C + pad2} V${C + pad2} H${-pad2}Z`;
1561
1654
  return SQUIRCLE;
1562
1655
  }
1563
1656
 
@@ -1583,6 +1676,17 @@ function hexLuma(hex) {
1583
1676
  const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
1584
1677
  return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
1585
1678
  }
1679
+ var NAMED = { white: 1, black: 0 };
1680
+ function paintLuma(v) {
1681
+ if (v.startsWith("#")) return hexLuma(v);
1682
+ const m = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/i.exec(v);
1683
+ if (m) {
1684
+ const ch = (x) => Math.min(1, Math.max(0, x.endsWith("%") ? Number(x.slice(0, -1)) / 100 : Number(x) / 255));
1685
+ return luminance({ r: ch(m[1]), g: ch(m[2]), b: ch(m[3]), a: 1 });
1686
+ }
1687
+ const named = NAMED[v.toLowerCase()];
1688
+ return named ?? null;
1689
+ }
1586
1690
  function assetLuma(a) {
1587
1691
  const hit = cache2.get(a.bytes);
1588
1692
  if (hit !== void 0) return hit;
@@ -1603,10 +1707,11 @@ function assetLuma(a) {
1603
1707
  }
1604
1708
  } else if (a.kind === "svg") {
1605
1709
  const text2 = new TextDecoder().decode(a.display ?? a.bytes);
1606
- const found = [...text2.matchAll(/(?:fill|stop-color)\s*[:=]\s*["']?\s*(#[0-9a-f]{3,8})\b/gi)].map((m) => hexLuma(m[1])).filter((x) => x !== null);
1607
- const named = [...text2.matchAll(/(?:fill|stop-color)\s*[:=]\s*["']?\s*(white|black)\b/gi)].map((m) => m[1].toLowerCase() === "white" ? 1 : 0);
1608
- const all = [...found, ...named];
1609
- if (all.length) l = all.reduce((x, y) => x + y, 0) / all.length;
1710
+ const paint = /(?:fill|stop-color)\s*[:=]\s*["']?\s*(rgba?\([^)]*\)|[^"';>\s]+)/gi;
1711
+ const values = [...text2.matchAll(paint)].map((m) => m[1].trim()).filter((v) => !/^(none|transparent|inherit|currentcolor|url\()/i.test(v));
1712
+ const ls = values.map(paintLuma).filter((x) => x !== null);
1713
+ if (ls.length) l = ls.reduce((x, y) => x + y, 0) / ls.length;
1714
+ else l = values.length ? 0.5 : 0;
1610
1715
  }
1611
1716
  cache2.set(a.bytes, l);
1612
1717
  return l;
@@ -1624,6 +1729,7 @@ function rasterRenderOptions(ro) {
1624
1729
  };
1625
1730
  }
1626
1731
  var C2 = CANVAS;
1732
+ var PAD = C2 / 4;
1627
1733
  var H2 = C2 / 2;
1628
1734
  var b64Cache = /* @__PURE__ */ new WeakMap();
1629
1735
  var b64 = (bytes) => {
@@ -1642,11 +1748,32 @@ var b64 = (bytes) => {
1642
1748
  };
1643
1749
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
1644
1750
  var n = (x) => +x.toFixed(3);
1645
- function hexToRGBA(hex) {
1646
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
1647
- if (!m) return { r: 0.25, g: 0.65, b: 0.63, a: 1 };
1648
- const v = parseInt(m[1], 16);
1649
- return { r: (v >> 16 & 255) / 255, g: (v >> 8 & 255) / 255, b: (v & 255) / 255, a: 1 };
1751
+ var DEFAULT_TINT = { r: 0.25, g: 0.65, b: 0.63, a: 1 };
1752
+ function tintToRGBA(input) {
1753
+ try {
1754
+ const c = parseColor(normalizeColorInput(input));
1755
+ return c ? { ...toRGBA(c), a: 1 } : DEFAULT_TINT;
1756
+ } catch {
1757
+ return DEFAULT_TINT;
1758
+ }
1759
+ }
1760
+ var num2 = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
1761
+ function safePosition(p) {
1762
+ if (!p || typeof p !== "object") return null;
1763
+ const o = p;
1764
+ const t = Array.isArray(o["translation-in-points"]) ? o["translation-in-points"] : [];
1765
+ const scale = num2(o.scale, 1);
1766
+ return { scale: scale > 0 ? scale : 1, tx: num2(t[0], 0), ty: num2(t[1], 0) };
1767
+ }
1768
+ function safeOrientation(o) {
1769
+ const pt = (v) => {
1770
+ const q = v;
1771
+ return q && typeof q === "object" && Number.isFinite(q.x) && Number.isFinite(q.y) ? { x: q.x, y: q.y } : null;
1772
+ };
1773
+ const r = o;
1774
+ const start = r && typeof r === "object" ? pt(r.start) : null;
1775
+ const stop = r && typeof r === "object" ? pt(r.stop) : null;
1776
+ return start && stop ? { start, stop } : void 0;
1650
1777
  }
1651
1778
  var colorOf = (s) => {
1652
1779
  const c = typeof s === "string" ? parseColor(s) : null;
@@ -1693,10 +1820,10 @@ function paintFor(fill, defs, box, darkSystem) {
1693
1820
  const c = colorOf(f["automatic-gradient"]);
1694
1821
  return lin(shade(c, 0.22), shade(c, -0.08));
1695
1822
  }
1696
- if (Array.isArray(f["linear-gradient"])) {
1823
+ if (Array.isArray(f["linear-gradient"]) && f["linear-gradient"].length) {
1697
1824
  const [a, b] = f["linear-gradient"];
1698
- const o = f.orientation;
1699
- return lin(colorOf(a), colorOf(b), o?.start, o?.stop);
1825
+ const o = safeOrientation(f.orientation);
1826
+ return lin(colorOf(a), colorOf(b ?? a), o?.start, o?.stop);
1700
1827
  }
1701
1828
  return null;
1702
1829
  }
@@ -1709,22 +1836,25 @@ function renderSvg(doc, opts = {}) {
1709
1836
  const dark = isDarkAppearance(appearance);
1710
1837
  const isClear = appearance === "clear-light" || appearance === "clear-dark";
1711
1838
  const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
1712
- const tint = hexToRGBA(opts.tint ?? "#3FA7A0");
1839
+ const tint = opts.tint ? tintToRGBA(opts.tint) : DEFAULT_TINT;
1840
+ const idioms = idiomsFor(ctx.idiom);
1841
+ const forPlatform = (s) => s.idiom === void 0 || idioms.includes(s.idiom);
1713
1842
  const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
1714
1843
  const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
1715
1844
  const defs = new Defs(opts.idPrefix ?? "");
1716
1845
  const body = [];
1717
1846
  const fillProp = doc.props.fill;
1718
1847
  let bgFill = resolveProp(fillProp, ctx, "automatic");
1719
- const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark");
1848
+ const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark" && forPlatform(s));
1720
1849
  let darken = 0;
1721
1850
  if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
1722
1851
  let bgPaint;
1723
- const P = mask === "circle" ? 32 : 0;
1852
+ const P = mask === "circle" || ctx.idiom === "watchOS" ? 32 : 0;
1724
1853
  const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
1725
- const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" || s.appearance === appearance);
1854
+ const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" && forPlatform(s));
1726
1855
  if (monoOverride) {
1727
- bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
1856
+ const mono = resolveProp(fillProp, { appearance: dark ? "tinted-dark" : "tinted-light", idiom: ctx.idiom }, "automatic");
1857
+ bgPaint = paintFor(mono ?? "automatic", defs, full, dark);
1728
1858
  } else if (isClear) {
1729
1859
  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);
1730
1860
  } else if (isTinted) {
@@ -1765,7 +1895,10 @@ function renderSvg(doc, opts = {}) {
1765
1895
  plainId = plain;
1766
1896
  }
1767
1897
  function drawGroup(g, backdropId) {
1768
- const gpos = resolveProp(g.props.position, ctx, null);
1898
+ const gp = safePosition(resolveProp(g.props.position, ctx, null));
1899
+ const gs = gp?.scale ?? 1;
1900
+ const gtx = gp?.tx ?? 0;
1901
+ const gty = gp?.ty ?? 0;
1769
1902
  const layers = FRONT_FIRST ? [...g.layers].reverse() : [...g.layers];
1770
1903
  const placed = [];
1771
1904
  for (const l of layers) {
@@ -1773,14 +1906,23 @@ function renderSvg(doc, opts = {}) {
1773
1906
  const name = resolveProp(l.props["image-name"], ctx, "");
1774
1907
  const asset = doc.assets[name];
1775
1908
  if (!asset || asset.kind === "pdf" || asset.kind === "other") continue;
1776
- const pos = resolveProp(l.props.position, ctx, null);
1909
+ if (!(asset.width > 0 && asset.height > 0 && Number.isFinite(asset.width) && Number.isFinite(asset.height))) continue;
1910
+ const pos = safePosition(resolveProp(l.props.position, ctx, null));
1777
1911
  const s = pos?.scale ?? 1;
1778
- const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
1779
1912
  const w = asset.width * s;
1780
1913
  const h = asset.height * s;
1781
- placed.push({ layer: l, asset, x: H2 + tx - w / 2, y: H2 + ty - h / 2, w, h });
1914
+ const x = H2 + (pos?.tx ?? 0) - w / 2;
1915
+ const y = H2 + (pos?.ty ?? 0) - h / 2;
1916
+ const cx0 = H2 + gtx + (x - H2) * gs;
1917
+ const cy0 = H2 + gty + (y - H2) * gs;
1918
+ const cx1 = cx0 + w * gs;
1919
+ const cy1 = cy0 + h * gs;
1920
+ if (cx1 < -P || cy1 < -P || cx0 > C2 + P || cy0 > C2 + P) continue;
1921
+ placed.push({ layer: l, asset, x, y, w, h });
1782
1922
  }
1783
1923
  if (!placed.length) return null;
1924
+ const groupOpacity = Math.min(1, Math.max(0, num2(resolveProp(g.props.opacity, ctx, 1), 1)));
1925
+ if (groupOpacity <= 0) return null;
1784
1926
  const imgId = /* @__PURE__ */ new Map();
1785
1927
  const layerEls = [];
1786
1928
  const glassEls = [];
@@ -1797,7 +1939,7 @@ function renderSvg(doc, opts = {}) {
1797
1939
  const s = p.w / p.asset.width;
1798
1940
  const place = `transform="translate(${n(p.x)} ${n(p.y)}) scale(${n(s)})"`;
1799
1941
  const fill = resolveProp(p.layer.props.fill, ctx, null);
1800
- const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
1942
+ const opacity = Math.min(1, Math.max(0, num2(resolveProp(p.layer.props.opacity, ctx, 1), 1)));
1801
1943
  const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
1802
1944
  const cb = contentBoxOf(p.asset);
1803
1945
  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 };
@@ -1805,7 +1947,7 @@ function renderSvg(doc, opts = {}) {
1805
1947
  let el;
1806
1948
  if (paint) {
1807
1949
  const mid = defs.id("lm");
1808
- 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>`);
1950
+ 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>`);
1809
1951
  el = `<rect x="${n(p.x)}" y="${n(p.y)}" width="${n(p.w)}" height="${n(p.h)}" fill="${paint}" mask="url(#${mid})"/>`;
1810
1952
  } else {
1811
1953
  el = `<use href="#${iid}" ${place}/>`;
@@ -1817,43 +1959,55 @@ function renderSvg(doc, opts = {}) {
1817
1959
  glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
1818
1960
  }
1819
1961
  }
1820
- const gTransform = gpos ? ` transform="translate(${n(H2 + (gpos["translation-in-points"]?.[0] ?? 0))} ${n(H2 + (gpos["translation-in-points"]?.[1] ?? 0))}) scale(${n(gpos.scale ?? 1)}) translate(${-H2} ${-H2})"` : "";
1962
+ const gTransform = gp ? ` transform="translate(${n(H2 + gtx)} ${n(H2 + gty)}) scale(${n(gs)}) translate(${-H2} ${-H2})"` : "";
1963
+ const canvasRegion = `filterUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"`;
1964
+ const gx0 = H2 + (-PAD - H2 - gtx) / gs;
1965
+ const gy0 = H2 + (-PAD - H2 - gty) / gs;
1966
+ const groupRegion = `filterUnits="userSpaceOnUse" x="${n(gx0)}" y="${n(gy0)}" width="${n((C2 + 2 * PAD) / gs)}" height="${n((C2 + 2 * PAD) / gs)}"`;
1821
1967
  const contentId = defs.id("gc");
1822
1968
  defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
1823
1969
  contentIds.push(contentId);
1824
1970
  const out = [];
1825
- const shadow = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
1971
+ const shadow0 = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
1972
+ const shadow = shadow0 && typeof shadow0 === "object" ? { kind: shadow0.kind, opacity: Math.min(1, Math.max(0, num2(shadow0.opacity, 0.5))) } : null;
1826
1973
  if (effects && shadow && shadow.kind !== "none" && shadow.opacity > 0) {
1827
1974
  const fid = defs.id("sh");
1828
1975
  if (shadow.kind === "neutral") {
1829
1976
  defs.add(
1830
- `<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>`
1977
+ `<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>`
1831
1978
  );
1832
1979
  } else {
1833
1980
  defs.add(
1834
- `<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>`
1981
+ `<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>`
1835
1982
  );
1836
1983
  }
1837
1984
  out.push(`<use href="#${contentId}" filter="url(#${fid})" opacity="${n(shadow.opacity)}"/>`);
1838
1985
  }
1839
- const tr = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
1840
- const blurMat = resolveProp(g.props["blur-material"], ctx, null);
1986
+ const tr0 = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
1987
+ const tr = tr0 && typeof tr0 === "object" ? { enabled: !!tr0.enabled, value: num2(tr0.value, 0.5) } : null;
1988
+ const blurRaw = resolveProp(g.props["blur-material"], ctx, void 0);
1989
+ const blurMat = blurRaw === null ? null : num2(blurRaw, 0.2);
1841
1990
  const translucent = effects && !!tr?.enabled && tr.value > 0;
1842
- const refr = resolveProp(g.props.refractivity, ctx, null);
1991
+ const refr0 = resolveProp(g.props.refractivity, ctx, null);
1992
+ const refr = refr0 && typeof refr0 === "object" ? { enabled: !!refr0.enabled, strength: num2(refr0.strength, 0), depth: num2(refr0.depth, 0) } : null;
1843
1993
  const refracts = effects && !!refr?.enabled && glassEls.length > 0;
1844
1994
  if (translucent || refracts) {
1845
1995
  const maskSrc = translucent ? `<use href="#${contentId}"/>` : `<g${gTransform}>${glassEls.join("")}</g>`;
1846
1996
  const mid = defs.id("gm");
1847
- defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="${C2}" height="${C2}">${maskSrc}</mask>`);
1848
- const fid = defs.id("bk");
1849
- const blurPx = 4 + (blurMat ?? 0.2) * 40 + (refracts ? (refr.depth ?? 0) * 10 : 0);
1850
- defs.add(`<filter id="${fid}" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
1851
- const zoom = 1.02 + (refracts ? (refr.strength ?? 0) * 0.12 : 0.015);
1997
+ 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>`);
1998
+ const blurPx = Math.max(0, (blurMat === null ? 0 : 4 + blurMat * 40) + (refracts ? refr.depth * 10 : 0));
1999
+ let filterAttr = "";
2000
+ if (blurPx > 0) {
2001
+ const fid = defs.id("bk");
2002
+ defs.add(`<filter id="${fid}" ${canvasRegion}><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
2003
+ filterAttr = ` filter="url(#${fid})"`;
2004
+ }
2005
+ const zoom = 1.02 + (refracts ? refr.strength * 0.12 : 0.015);
1852
2006
  out.push(
1853
- `<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
2007
+ `<g mask="url(#${mid})"><use href="#${backdropId}"${filterAttr} transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
1854
2008
  );
1855
2009
  }
1856
- const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
2010
+ const gOpacity = refracts && !translucent ? 1 - Math.min(1, Math.max(0, refr.strength)) * 0.3 : 1;
1857
2011
  let fadeMask = "";
1858
2012
  if (translucent) {
1859
2013
  const v = Math.min(1, Math.max(0, tr.value));
@@ -1879,15 +2033,15 @@ function renderSvg(doc, opts = {}) {
1879
2033
  const fid = defs.id("sp");
1880
2034
  if (specular === "outside") {
1881
2035
  defs.add(
1882
- `<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>`
2036
+ `<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>`
1883
2037
  );
1884
2038
  } else defs.add(
1885
- `<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>`
2039
+ `<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>`
1886
2040
  );
1887
2041
  const glass = individual ? glassEls.map((e) => `<g filter="url(#${fid})">${e}</g>`).join("") : `<g filter="url(#${fid})">${glassEls.join("")}</g>`;
1888
2042
  out.push(`<g${gTransform}>${glass}</g>`);
1889
2043
  }
1890
- return out.join("");
2044
+ return groupOpacity < 1 ? `<g opacity="${n(groupOpacity)}">${out.join("")}</g>` : out.join("");
1891
2045
  }
1892
2046
  let top = `<use href="#${stackId}"/>`;
1893
2047
  if ((isClear || isTinted) && contentIds.length) {
@@ -1909,18 +2063,19 @@ function renderSvg(doc, opts = {}) {
1909
2063
  }
1910
2064
  const mid = defs.id("fm");
1911
2065
  defs.add(
1912
- `<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>`
2066
+ `<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>`
1913
2067
  );
1914
2068
  top = `<use href="#${bgStackId}"/><g filter="url(#${fid})"><use href="#${stackId}" mask="url(#${mid})"/></g>`;
1915
2069
  }
1916
2070
  const clipId = defs.id("clip");
1917
- const shape = maskPath(mask);
2071
+ const shape = maskPath(mask, P);
1918
2072
  defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
1919
- const M = Math.max(0, opts.inset ?? 0);
2073
+ const inset = Math.min(C2 / 2 - 1, Math.max(0, num2(opts.inset, 0)));
2074
+ const M = inset > 0 ? inset * C2 / (C2 - 2 * inset) : 0;
1920
2075
  if (M > 0) {
1921
2076
  const sid = defs.id("ish");
1922
2077
  defs.add(
1923
- `<filter id="${sid}" x="-20%" y="-20%" width="140%" height="150%"><feGaussianBlur in="SourceAlpha" stdDeviation="${n(C2 * 0.012)}"/><feOffset dy="${n(C2 * 0.012)}" result="b"/><feFlood flood-color="#000" flood-opacity="0.3"/><feComposite in2="b" operator="in" result="s"/><feMerge><feMergeNode in="s"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
2078
+ `<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>`
1924
2079
  );
1925
2080
  body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
1926
2081
  } else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
@@ -1935,8 +2090,9 @@ function renderSvg(doc, opts = {}) {
1935
2090
  }
1936
2091
  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>`;
1937
2092
  }
2093
+ var KNOWN_BLEND = new Set(BLEND_MODES);
1938
2094
  function cssBlend(mode) {
1939
- if (!mode || mode === "normal") return null;
2095
+ if (typeof mode !== "string" || !KNOWN_BLEND.has(mode) || mode === "normal") return null;
1940
2096
  if (mode === "plus-lighter") return "screen";
1941
2097
  if (mode === "plus-darker") return "multiply";
1942
2098
  return mode;
@@ -1979,6 +2135,7 @@ async function resvg() {
1979
2135
  }
1980
2136
  }
1981
2137
  async function svgToPng(svg, width) {
2138
+ if (!(width >= 2)) throw new Error(`Image size must be at least 2 px (got ${width})`);
1982
2139
  const { Resvg } = await resvg();
1983
2140
  const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
1984
2141
  return new Uint8Array(r.render().asPng());
@@ -2107,7 +2264,7 @@ function convert(k, raw, flag) {
2107
2264
  switch (k.type) {
2108
2265
  case "number": {
2109
2266
  const n2 = Number(raw);
2110
- if (raw.trim() === "" || Number.isNaN(n2)) throw new Error(`${flag} expects a number, got "${raw}"`);
2267
+ if (!/^\s*[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?\s*$/i.test(raw) || Number.isNaN(n2)) throw new Error(`${flag} expects a number, got "${raw}"`);
2111
2268
  return n2;
2112
2269
  }
2113
2270
  case "array":
@@ -2122,59 +2279,89 @@ function convert(k, raw, flag) {
2122
2279
  return raw;
2123
2280
  }
2124
2281
  }
2282
+ var BOOL_TRUE = /^(true|1|yes|on)$/i;
2283
+ var BOOL_FALSE = /^(false|0|no|off)$/i;
2125
2284
  function parseToolArgv(t, argv) {
2126
2285
  const infos = argsOf(t);
2127
2286
  const byFlag = new Map(infos.map((a) => [a.flag, a]));
2128
2287
  const out = {};
2129
2288
  let base = {};
2289
+ const roots = [];
2290
+ let help = false;
2130
2291
  const positionals = [];
2292
+ let optionsDone = false;
2131
2293
  for (let i = 0; i < argv.length; i++) {
2132
2294
  const tok = argv[i];
2133
- if (!tok.startsWith("--") || tok === "--") {
2295
+ if (optionsDone || tok === "-" || !tok.startsWith("-")) {
2134
2296
  positionals.push(tok);
2135
2297
  continue;
2136
2298
  }
2299
+ if (tok === "--") {
2300
+ optionsDone = true;
2301
+ continue;
2302
+ }
2303
+ if (tok === "--help" || tok === "-h") {
2304
+ help = true;
2305
+ continue;
2306
+ }
2307
+ if (tok === "-r") {
2308
+ const v2 = argv[++i];
2309
+ if (v2 === void 0) throw new Error("-r needs a directory");
2310
+ roots.push(v2);
2311
+ continue;
2312
+ }
2313
+ if (!tok.startsWith("--")) throw new Error(`Unknown option ${tok}. Run "fresnel ${cliCommand(t)} --help".`);
2137
2314
  const eq = tok.indexOf("=");
2138
2315
  const flag = eq > 0 ? tok.slice(0, eq) : tok;
2139
2316
  const inline = eq > 0 ? tok.slice(eq + 1) : void 0;
2317
+ const value = () => {
2318
+ const raw = inline ?? argv[++i];
2319
+ if (raw === void 0) throw new Error(`${flag} needs a value`);
2320
+ return raw;
2321
+ };
2322
+ if (flag === "--root") {
2323
+ roots.push(value());
2324
+ continue;
2325
+ }
2140
2326
  if (flag === "--args") {
2141
- const raw2 = inline ?? argv[++i];
2142
- if (raw2 === void 0) throw new Error("--args needs a JSON object");
2143
- const v = convert({ type: "json" }, raw2, "--args");
2144
- if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("--args must be a JSON object");
2145
- base = v;
2327
+ const v2 = convert({ type: "json" }, value(), "--args");
2328
+ if (!v2 || typeof v2 !== "object" || Array.isArray(v2)) throw new Error("--args must be a JSON object");
2329
+ base = { ...base, ...v2 };
2146
2330
  continue;
2147
2331
  }
2148
2332
  const neg = flag.startsWith("--no-") ? byFlag.get(`--${flag.slice(5)}`) : void 0;
2149
- if (neg && neg.kind.type === "boolean") {
2333
+ if (neg && neg.kind.type === "boolean" && inline === void 0) {
2150
2334
  out[neg.name] = false;
2151
2335
  continue;
2152
2336
  }
2153
2337
  const info = byFlag.get(flag);
2154
2338
  if (!info) throw new Error(`Unknown option ${flag} for ${t.id}. Run "fresnel ${cliCommand(t)} --help".`);
2155
2339
  if (info.kind.type === "boolean") {
2156
- out[info.name] = inline === void 0 ? true : !/^(false|0|no)$/i.test(inline);
2340
+ if (inline === void 0) out[info.name] = true;
2341
+ else if (BOOL_TRUE.test(inline)) out[info.name] = true;
2342
+ else if (BOOL_FALSE.test(inline)) out[info.name] = false;
2343
+ else throw new Error(`${flag} expects true or false, got "${inline}"`);
2157
2344
  continue;
2158
2345
  }
2159
- const raw = inline ?? argv[++i];
2160
- if (raw === void 0) throw new Error(`${flag} needs a value`);
2161
- out[info.name] = convert(info.kind, raw, flag);
2346
+ const v = convert(info.kind, value(), flag);
2347
+ out[info.name] = info.kind.type === "array" && Array.isArray(out[info.name]) ? [...out[info.name], ...v] : v;
2162
2348
  }
2163
2349
  const pos = t.positional ?? [];
2164
2350
  if (positionals.length > pos.length) throw new Error(`Unexpected argument "${positionals[pos.length]}". Run "fresnel ${cliCommand(t)} --help".`);
2165
2351
  positionals.forEach((v, i) => {
2166
2352
  const info = infos.find((a) => a.name === pos[i]);
2167
- if (out[info.name] === void 0) out[info.name] = convert(info.kind, v, `<${info.name}>`);
2353
+ if (out[info.name] !== void 0) throw new Error(`<${info.name}> was given twice (as an argument and as ${info.flag})`);
2354
+ out[info.name] = convert(info.kind, v, `<${info.name}>`);
2168
2355
  });
2169
- return { ...base, ...out };
2356
+ return { args: { ...base, ...out }, roots, help };
2170
2357
  }
2171
2358
 
2172
2359
  // ../mcp/src/contract.ts
2173
2360
  import { z as z3 } from "zod";
2174
2361
 
2175
2362
  // ../mcp/src/spec.ts
2176
- import { mkdir as mkdir2, readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
2177
- import { basename, dirname as dirname2, join as join2 } from "path";
2363
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
2364
+ import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, sep as sep2 } from "path";
2178
2365
  import { z as z2 } from "zod";
2179
2366
  var CONTRACT_VERSION = "1.0";
2180
2367
  var tool = (def) => def;
@@ -2189,6 +2376,7 @@ var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.arr
2189
2376
  function describeProps(props) {
2190
2377
  const base = {};
2191
2378
  const overrides = {};
2379
+ const variants = /* @__PURE__ */ new Map();
2192
2380
  for (const [k, p] of Object.entries(props)) {
2193
2381
  const b = p.specs?.find((s) => s.appearance === void 0 && s.idiom === void 0);
2194
2382
  if (b) base[k] = b.value;
@@ -2197,21 +2385,32 @@ function describeProps(props) {
2197
2385
  const s = p.specs.find((e) => e.appearance === a && e.idiom === void 0);
2198
2386
  (overrides[a] ??= {})[k] = s.value;
2199
2387
  }
2388
+ for (const s of p.specs ?? []) {
2389
+ if (s.idiom === void 0) continue;
2390
+ const key = `${s.appearance ?? ""}|${s.idiom}`;
2391
+ if (!variants.has(key)) variants.set(key, { ...s.appearance ? { appearance: s.appearance } : {}, platform: s.idiom, values: {} });
2392
+ variants.get(key).values[k] = s.value;
2393
+ }
2200
2394
  }
2201
- return Object.keys(overrides).length ? { ...base, overrides } : base;
2395
+ return {
2396
+ ...base,
2397
+ ...Object.keys(overrides).length ? { overrides } : {},
2398
+ ...variants.size ? { variants: [...variants.values()] } : {}
2399
+ };
2202
2400
  }
2203
2401
  function inspect(doc) {
2204
2402
  return {
2205
2403
  order: "groups and layers are listed front to back (index 0 = frontmost)",
2206
2404
  document: describeProps(doc.props),
2405
+ // attributes first, so an unknown key in the file (e.g. "target") can't replace these fields
2207
2406
  groups: doc.groups.map((g, gi) => ({
2407
+ ...describeProps(g.props),
2208
2408
  target: nameOf(g) || `#${gi}`,
2209
2409
  index: gi,
2210
- ...describeProps(g.props),
2211
2410
  layers: g.layers.map((l, li) => ({
2411
+ ...describeProps(l.props),
2212
2412
  target: `${nameOf(g) || `#${gi}`}/${nameOf(l) || `#${li}`}`,
2213
- index: li,
2214
- ...describeProps(l.props)
2413
+ index: li
2215
2414
  }))
2216
2415
  })),
2217
2416
  assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
@@ -2229,9 +2428,7 @@ function targetLabel(doc, ref) {
2229
2428
  }
2230
2429
  async function saveFile(ws, out, bytes) {
2231
2430
  const p = await ws.resolve(out);
2232
- await mkdir2(dirname2(p), { recursive: true });
2233
- await writeFile2(p, bytes);
2234
- return p;
2431
+ return (await writeFiles(dirname2(p), { [basename(p)]: bytes }))[0];
2235
2432
  }
2236
2433
  var inspectOutput = loose({ order: z2.string(), document: z2.record(z2.string(), z2.unknown()), groups: z2.array(z2.looseObject({ target: z2.string(), index: z2.number() })), assets: z2.array(z2.looseObject({ name: z2.string() })) });
2237
2434
  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>';
@@ -2256,7 +2453,9 @@ var TOOLS = [
2256
2453
  },
2257
2454
  output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
2258
2455
  mutates_input: true,
2456
+ writes_files: true,
2259
2457
  idempotency_hint: "not-idempotent",
2458
+ destructive: true,
2260
2459
  supports_dry_run: true,
2261
2460
  verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
2262
2461
  requires_visual_verification: false,
@@ -2264,14 +2463,16 @@ var TOOLS = [
2264
2463
  async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
2265
2464
  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)");
2266
2465
  const full = await ws.iconPath(path);
2267
- const exists = await stat2(full).then(() => true, () => false);
2268
- if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
2269
2466
  const doc = createDocument({
2270
2467
  ...background ? { background } : {},
2271
2468
  platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
2272
2469
  });
2273
2470
  if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
2274
- if (!dry_run) await ws.withLock(full, () => saveIcon(full, doc));
2471
+ await ws.withLock(full, async () => {
2472
+ const exists = await stat2(full).then(() => true, () => false);
2473
+ if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
2474
+ if (!dry_run) await saveIcon(full, doc);
2475
+ });
2275
2476
  return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
2276
2477
  }
2277
2478
  }),
@@ -2287,6 +2488,7 @@ var TOOLS = [
2287
2488
  input: { path: pathArg },
2288
2489
  output: inspectOutput,
2289
2490
  mutates_input: false,
2491
+ writes_files: false,
2290
2492
  idempotency_hint: "idempotent",
2291
2493
  supports_dry_run: false,
2292
2494
  verification: [],
@@ -2315,6 +2517,7 @@ var TOOLS = [
2315
2517
  },
2316
2518
  output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
2317
2519
  mutates_input: true,
2520
+ writes_files: false,
2318
2521
  idempotency_hint: "not-idempotent",
2319
2522
  supports_dry_run: true,
2320
2523
  verification: ["icon_inspect lists the new group"],
@@ -2357,6 +2560,7 @@ var TOOLS = [
2357
2560
  },
2358
2561
  output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
2359
2562
  mutates_input: true,
2563
+ writes_files: false,
2360
2564
  idempotency_hint: "not-idempotent",
2361
2565
  supports_dry_run: true,
2362
2566
  verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
@@ -2432,6 +2636,7 @@ var TOOLS = [
2432
2636
  },
2433
2637
  output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
2434
2638
  mutates_input: true,
2639
+ writes_files: false,
2435
2640
  idempotency_hint: "idempotent",
2436
2641
  supports_dry_run: true,
2437
2642
  verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
@@ -2467,6 +2672,7 @@ var TOOLS = [
2467
2672
  input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
2468
2673
  output: loose({ groups: groupsOutline }),
2469
2674
  mutates_input: true,
2675
+ writes_files: false,
2470
2676
  idempotency_hint: "idempotent",
2471
2677
  supports_dry_run: true,
2472
2678
  verification: ["`groups` lists the new order (front to back)"],
@@ -2480,6 +2686,7 @@ var TOOLS = [
2480
2686
  const ref = resolvePath(doc, target);
2481
2687
  let gid;
2482
2688
  if (to_group) {
2689
+ if (ref.kind !== "layer") throw new Error("to_group only applies to layers; groups are reordered with index");
2483
2690
  const g = resolvePath(doc, to_group);
2484
2691
  if (g.kind !== "group") throw new Error("to_group must be a group");
2485
2692
  gid = g.id;
@@ -2511,6 +2718,7 @@ var TOOLS = [
2511
2718
  },
2512
2719
  output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
2513
2720
  mutates_input: true,
2721
+ writes_files: false,
2514
2722
  idempotency_hint: "idempotent",
2515
2723
  supports_dry_run: true,
2516
2724
  verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
@@ -2546,6 +2754,7 @@ var TOOLS = [
2546
2754
  input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
2547
2755
  output: loose({ removed: z2.string(), groups: groupsOutline }),
2548
2756
  mutates_input: true,
2757
+ writes_files: false,
2549
2758
  idempotency_hint: "not-idempotent",
2550
2759
  destructive: true,
2551
2760
  supports_dry_run: true,
@@ -2582,6 +2791,7 @@ var TOOLS = [
2582
2791
  issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
2583
2792
  }),
2584
2793
  mutates_input: false,
2794
+ writes_files: false,
2585
2795
  idempotency_hint: "idempotent",
2586
2796
  supports_dry_run: false,
2587
2797
  verification: ['No issue with severity "error"'],
@@ -2608,12 +2818,13 @@ var TOOLS = [
2608
2818
  platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
2609
2819
  size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
2610
2820
  mask: maskEnum.optional(),
2611
- tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2821
+ 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"'),
2612
2822
  light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
2613
2823
  out: z2.string().optional().describe("Save the PNG to this path")
2614
2824
  },
2615
2825
  output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
2616
2826
  mutates_input: false,
2827
+ writes_files: true,
2617
2828
  idempotency_hint: "idempotent",
2618
2829
  supports_dry_run: false,
2619
2830
  verification: ["Look at the returned image"],
@@ -2646,11 +2857,12 @@ var TOOLS = [
2646
2857
  input: {
2647
2858
  path: pathArg,
2648
2859
  cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
2649
- tint: z2.string().optional(),
2860
+ 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"'),
2650
2861
  out: z2.string().optional()
2651
2862
  },
2652
2863
  output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
2653
2864
  mutates_input: false,
2865
+ writes_files: true,
2654
2866
  idempotency_hint: "idempotent",
2655
2867
  supports_dry_run: false,
2656
2868
  verification: ["Look at the returned image: every appearance should stay legible"],
@@ -2676,12 +2888,14 @@ var TOOLS = [
2676
2888
  path: pathArg,
2677
2889
  out_dir: z2.string(),
2678
2890
  formats: z2.array(z2.enum(["icon", "icns", "appiconset", "png"])).min(1),
2679
- sizes: z2.array(z2.number().int().min(16).max(2048)).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2680
- appearances: z2.array(appearanceEnum).optional().describe("PNG appearances, default all six")
2891
+ sizes: z2.array(z2.number().int().min(16).max(2048)).min(1).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2892
+ appearances: z2.array(appearanceEnum).min(1).optional().describe("PNG appearances, default all six")
2681
2893
  },
2682
2894
  output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
2683
2895
  mutates_input: false,
2896
+ writes_files: true,
2684
2897
  idempotency_hint: "idempotent",
2898
+ destructive: true,
2685
2899
  supports_dry_run: false,
2686
2900
  verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
2687
2901
  requires_visual_verification: false,
@@ -2689,6 +2903,9 @@ var TOOLS = [
2689
2903
  async run(ws, { path, out_dir, formats, sizes, appearances }) {
2690
2904
  const full = await ws.iconPath(path, { mustExist: true });
2691
2905
  const outDir = await ws.resolve(out_dir);
2906
+ const inside = relative2(full, outDir);
2907
+ if (inside === "" || !inside.startsWith(`..${sep2}`) && inside !== ".." && !isAbsolute2(inside))
2908
+ throw new Error("out_dir is inside the icon bundle; choose a folder outside it");
2692
2909
  const doc = await ws.read(full);
2693
2910
  const name = basename(full).replace(/\.icon(\.zip)?$/, "");
2694
2911
  const raster = nodeRasterizer(doc);
@@ -2697,16 +2914,9 @@ var TOOLS = [
2697
2914
  throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them first (icon_validate).`);
2698
2915
  const written = [];
2699
2916
  if (formats.includes("icon")) {
2700
- const dest = join2(outDir, `${name}.icon`);
2701
- await saveIcon(dest, doc);
2702
- written.push(join2(dest, "icon.json"), ...Object.keys(doc.assets).map((a) => join2(dest, "Assets", a)));
2703
- }
2704
- if (formats.includes("icns")) {
2705
- const p = join2(outDir, `${name}.icns`);
2706
- await mkdir2(dirname2(p), { recursive: true });
2707
- await writeFile2(p, await buildIcns(raster));
2708
- written.push(p);
2917
+ written.push(...await saveIcon(join2(outDir, `${name}.icon`), doc));
2709
2918
  }
2919
+ if (formats.includes("icns")) written.push(...await writeFiles(outDir, { [`${name}.icns`]: await buildIcns(raster) }));
2710
2920
  if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
2711
2921
  if (formats.includes("png"))
2712
2922
  written.push(
@@ -2725,7 +2935,7 @@ async function runTool(ws, t, args) {
2725
2935
  }
2726
2936
 
2727
2937
  // ../mcp/src/version.ts
2728
- var VERSION = "0.2.0";
2938
+ var VERSION = "0.2.1";
2729
2939
 
2730
2940
  // ../mcp/src/contract.ts
2731
2941
  var RESOURCES = [
@@ -2762,6 +2972,7 @@ function toolSpec(t) {
2762
2972
  input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
2763
2973
  output_schema: jsonSchema(t.output, "output"),
2764
2974
  mutates_input: t.mutates_input,
2975
+ writes_files: t.writes_files,
2765
2976
  idempotency_hint: t.idempotency_hint,
2766
2977
  destructive: !!t.destructive,
2767
2978
  supports_dry_run: t.supports_dry_run,
@@ -2794,7 +3005,7 @@ function buildContract() {
2794
3005
  rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
2795
3006
  },
2796
3007
  tools: TOOLS.map(toolSpec),
2797
- capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a.localeCompare(b))),
3008
+ capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
2798
3009
  resources: RESOURCES,
2799
3010
  prompts: PROMPTS,
2800
3011
  icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
@@ -2892,10 +3103,11 @@ function createServer(ws) {
2892
3103
  {
2893
3104
  title: t.title,
2894
3105
  description: t.description,
2895
- inputSchema: t.input,
3106
+ // strict: a misspelt argument (dryRun for dry_run) is an error, not silently dropped
3107
+ inputSchema: z4.object(t.input).strict(),
2896
3108
  outputSchema: t.output,
2897
3109
  annotations: {
2898
- readOnlyHint: !t.mutates_input,
3110
+ readOnlyHint: !t.mutates_input && !t.writes_files,
2899
3111
  destructiveHint: !!t.destructive,
2900
3112
  idempotentHint: t.idempotency_hint === "idempotent",
2901
3113
  openWorldHint: false
@@ -2969,8 +3181,8 @@ Use the fresnel tools:
2969
3181
  }
2970
3182
 
2971
3183
  // ../mcp/src/workspace.ts
2972
- import { realpath, stat as stat3 } from "fs/promises";
2973
- import { dirname as dirname3, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
3184
+ import { lstat as lstat2, realpath, stat as stat3 } from "fs/promises";
3185
+ import { dirname as dirname3, isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
2974
3186
  var Workspace = class {
2975
3187
  roots;
2976
3188
  locks = /* @__PURE__ */ new Map();
@@ -2983,13 +3195,17 @@ var Workspace = class {
2983
3195
  /** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
2984
3196
  async resolve(p, opts = {}) {
2985
3197
  if (!p || typeof p !== "string") throw new Error("A path is required");
2986
- const abs = isAbsolute2(p) ? resolve2(p) : resolve2(this.roots[0], p);
3198
+ const abs = isAbsolute3(p) ? resolve2(p) : resolve2(this.roots[0], p);
2987
3199
  let probe = abs;
2988
3200
  let suffix = "";
2989
3201
  for (; ; ) {
2990
3202
  try {
2991
3203
  const real = await realpath(probe);
2992
3204
  const full = suffix ? resolve2(real, suffix) : real;
3205
+ if (suffix) {
3206
+ const first = resolve2(real, suffix.split(sep3)[0]);
3207
+ if (await lstat2(first).then(() => true, () => false)) throw new Error(`Access denied: ${p} goes through a symbolic link that points nowhere`);
3208
+ }
2993
3209
  if (!await this.allowed(full)) throw new Error(`Access denied: ${p} is outside the allowed roots (${this.roots.join(", ")})`);
2994
3210
  if (opts.mustExist && full !== real) throw new Error(`Not found: ${p}`);
2995
3211
  return full;
@@ -2998,7 +3214,7 @@ var Workspace = class {
2998
3214
  if (opts.mustExist) throw new Error(`Not found: ${p}`);
2999
3215
  const parent = dirname3(probe);
3000
3216
  if (parent === probe) throw new Error(`Invalid path: ${p}`);
3001
- suffix = suffix ? `${probe.slice(parent.length + 1)}${sep2}${suffix}` : probe.slice(parent.length + 1);
3217
+ suffix = suffix ? `${probe.slice(parent.length + 1)}${sep3}${suffix}` : probe.slice(parent.length + 1);
3002
3218
  probe = parent;
3003
3219
  }
3004
3220
  }
@@ -3011,15 +3227,17 @@ var Workspace = class {
3011
3227
  root = await realpath(r);
3012
3228
  } catch {
3013
3229
  }
3014
- const rel = relative2(root, full);
3015
- if (rel === "" || !rel.startsWith("..") && !isAbsolute2(rel)) return true;
3230
+ const rel = relative3(root, full);
3231
+ if (rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute3(rel)) return true;
3016
3232
  }
3017
3233
  return false;
3018
3234
  }
3019
3235
  /** Normalizes an icon path: accepts "Foo", "Foo.icon", ".../Foo.icon/icon.json". */
3020
3236
  async iconPath(p, opts = {}) {
3021
- let q = p.trim();
3237
+ let q = (typeof p === "string" ? p : "").trim().replace(/[\\/]+$/, "");
3022
3238
  if (q.endsWith("/icon.json") || q.endsWith("\\icon.json")) q = q.slice(0, -"/icon.json".length);
3239
+ const base = q.split(/[\\/]/).pop() ?? "";
3240
+ if (!base || base === ".icon" || base === ".icon.zip" || base === "." || base === "..") throw new Error(`Invalid icon path "${p}": give a name such as "MyApp.icon"`);
3023
3241
  if (!q.endsWith(".icon") && !q.endsWith(".icon.zip")) q = `${q}.icon`;
3024
3242
  const full = await this.resolve(q, opts);
3025
3243
  if (opts.mustExist) {
@@ -3081,23 +3299,13 @@ Appearances: ${APPEARANCES.join(", ")}
3081
3299
  Docs: https://github.com/kajisho5/fresnel
3082
3300
  `;
3083
3301
  async function callTool(t, argv) {
3084
- if (argv.includes("--help") || argv.includes("-h")) {
3302
+ const { args, roots, help } = parseToolArgv(t, argv);
3303
+ if (help) {
3085
3304
  process.stdout.write(toolHelp(t));
3086
3305
  return;
3087
3306
  }
3088
- const roots = [];
3089
- const rest = [];
3090
- for (let i = 0; i < argv.length; i++) {
3091
- const a = argv[i];
3092
- if (a === "--root" || a === "-r") {
3093
- const v = argv[++i];
3094
- if (!v) throw new Error("--root needs a directory");
3095
- roots.push(v);
3096
- } else if (a.startsWith("--root=")) roots.push(a.slice(7));
3097
- else rest.push(a);
3098
- }
3099
3307
  const ws = new Workspace(roots, { unrestricted: roots.length === 0 });
3100
- const { data, image } = await runTool(ws, t, parseToolArgv(t, rest));
3308
+ const { data, image } = await runTool(ws, t, args);
3101
3309
  if (image && !data.saved) process.stderr.write("fresnel: the PNG was not saved; pass --out <file.png> to keep it\n");
3102
3310
  process.stdout.write(`${JSON.stringify(data, null, 2)}
3103
3311
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fresnel-icon",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "fresnel CLI: build, validate, render and export Liquid Glass app icons (.icon bundles) from the command line or CI",
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/core": "0.2.0",
42
- "@fresnel/render": "0.2.0"
41
+ "@fresnel/core": "0.2.1",
42
+ "@fresnel/render": "0.2.1"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsup",