fresnel-icon 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/cli.js +371 -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
  }
@@ -990,6 +1051,11 @@ function addLayer(doc, groupId, asset, opts = {}) {
990
1051
  return l;
991
1052
  }
992
1053
  function setProp(doc, ref, key, value, appearance, detached) {
1054
+ const old = key === "image-name" && !detached && ref.kind === "layer" ? imagesOf([findLayer(doc, ref.id)?.layer].filter((l) => !!l)) : null;
1055
+ setPropRaw(doc, ref, key, value, appearance, detached);
1056
+ if (old?.size) pruneAssets(doc, old);
1057
+ }
1058
+ function setPropRaw(doc, ref, key, value, appearance, detached) {
993
1059
  const node = detached ? { kind: "layer", props: detached.props } : nodeOf(doc, ref);
994
1060
  if (key === "layers" || key === "groups") throw new OpError(`"${key}" can't be set directly`);
995
1061
  if (key.endsWith("-specializations")) throw new OpError(`Set "${key.replace(/-specializations$/, "")}" with an appearance instead`);
@@ -1018,31 +1084,40 @@ function normalizeFillColors(f) {
1018
1084
  const o = { ...f };
1019
1085
  if (typeof o.solid === "string") o.solid = normalizeColorInput(o.solid);
1020
1086
  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);
1087
+ if (Array.isArray(o["linear-gradient"])) {
1088
+ if (!o["linear-gradient"].every((c) => typeof c === "string")) throw new OpError("linear-gradient takes a list of color strings");
1089
+ o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
1090
+ }
1022
1091
  return o;
1023
1092
  }
1024
1093
  function removeNode(doc, ref) {
1025
1094
  if (ref.kind === "document") throw new OpError("The document can't be removed");
1095
+ let removedImages;
1026
1096
  if (ref.kind === "group") {
1027
1097
  const i = doc.groups.findIndex((g) => g.id === ref.id);
1028
1098
  if (i < 0) throw new OpError("Group not found");
1099
+ removedImages = imagesOf(doc.groups[i].layers);
1029
1100
  doc.groups.splice(i, 1);
1030
1101
  } else {
1031
1102
  const f = findLayer(doc, ref.id);
1032
1103
  if (!f) throw new OpError("Layer not found");
1104
+ removedImages = imagesOf([f.layer]);
1033
1105
  f.group.layers.splice(f.index, 1);
1034
1106
  }
1035
- pruneAssets(doc);
1107
+ pruneAssets(doc, removedImages);
1036
1108
  }
1037
- function pruneAssets(doc) {
1109
+ function imagesOf(layers) {
1038
1110
  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];
1111
+ for (const l of layers) {
1112
+ const p = l.props["image-name"];
1113
+ if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
1114
+ for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
1115
+ }
1116
+ return used;
1117
+ }
1118
+ function pruneAssets(doc, only) {
1119
+ const used = imagesOf(doc.groups.flatMap((g) => g.layers));
1120
+ for (const k of Object.keys(doc.assets)) if (!used.has(k) && (!only || only.has(k))) delete doc.assets[k];
1046
1121
  }
1047
1122
  function moveNode(doc, ref, index, toGroupId) {
1048
1123
  if (ref.kind === "document") throw new OpError("The document can't be moved");
@@ -1109,9 +1184,10 @@ function validate(doc) {
1109
1184
  let anyTinted = false;
1110
1185
  const scan = (props) => {
1111
1186
  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;
1187
+ for (const v of variantsOf(p)) {
1188
+ if (v.appearance === "dark") anyDark = true;
1189
+ if (v.appearance === "tinted") anyTinted = true;
1190
+ }
1115
1191
  }
1116
1192
  };
1117
1193
  scan(doc.props);
@@ -1142,19 +1218,22 @@ function validate(doc) {
1142
1218
  }
1143
1219
  if (Math.abs(a.width - a.height) > 0.5) {
1144
1220
  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);
1221
+ } else if (a.kind !== "svg" && pos.scale > 1.01) {
1222
+ push("V-03", `"${n2}" (${a.width}\xD7${a.height} px) is scaled up ${round(pos.scale)}\xD7 and may look soft. Use a larger image or a vector.`, lName, lref);
1147
1223
  }
1148
1224
  if (a.kind === "png" && a.width === a.height && a.width >= 256) {
1149
1225
  const bmp = safeDecode(a.bytes);
1150
1226
  if (bmp && looksPreMasked(bmp)) push("V-04", `"${n2}" seems to have rounded corners baked in. The system masks icons itself.`, lName, lref);
1151
1227
  }
1152
1228
  if (a.kind === "svg") {
1153
- const f = svgFacts(assetText(a));
1229
+ const text2 = assetText(a);
1230
+ const f = svgFacts(text2);
1231
+ const vb = /<svg\b[^>]*\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)/i.exec(text2);
1232
+ const unit2 = vb && Number(vb[1]) > 0 ? svgSize(text2).width / Number(vb[1]) : 1;
1154
1233
  if (f.hasText) push("V-07", `"${n2}" contains <text>. Convert text to outlines.`, lName, lref);
1155
1234
  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);
1235
+ if (f.minStrokeWidth !== null && f.minStrokeWidth * unit2 * pos.scale < 10)
1236
+ push("V-06", `"${n2}" has strokes as thin as ${round(f.minStrokeWidth * unit2)} pt. They'll be under 0.16 px at 16 px.`, lName, lref);
1158
1237
  }
1159
1238
  }
1160
1239
  }
@@ -1370,10 +1449,24 @@ function moveBy(doc, ref, dx, dy, ctx, variant) {
1370
1449
  props = f.layer.props;
1371
1450
  scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
1372
1451
  }
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
1452
  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);
1453
+ const shift = (p) => {
1454
+ const q = p ?? { scale: 1, "translation-in-points": [0, 0] };
1455
+ const [x, y] = q["translation-in-points"] ?? [0, 0];
1456
+ return { ...q, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] };
1457
+ };
1458
+ const vv = toVariant(variant);
1459
+ if (vv.appearance !== void 0 || vv.idiom !== void 0) {
1460
+ const cur = variantValue(props.position, vv) ?? resolveProp(props.position, ctx, null);
1461
+ setProp(doc, ref, "position", shift(cur), variant);
1462
+ return;
1463
+ }
1464
+ const pos = props.position;
1465
+ setProp(doc, ref, "position", shift(baseValue(pos)), void 0);
1466
+ for (const e of pos?.specs ?? []) {
1467
+ if (e.appearance === void 0 && e.idiom === void 0) continue;
1468
+ if (e.value && typeof e.value === "object") e.value = shift(e.value);
1469
+ }
1377
1470
  }
1378
1471
  function alignNodes(doc, refs, how, ctx, opts = {}) {
1379
1472
  const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
@@ -1454,6 +1547,7 @@ async function saveIcon(path, doc) {
1454
1547
  const assetsDir = join(root, "Assets");
1455
1548
  await assertNoSymlink(root, assetsDir);
1456
1549
  await mkdir(assetsDir, { recursive: true });
1550
+ const written = [];
1457
1551
  for (const [rel, bytes] of Object.entries(files)) {
1458
1552
  const target = resolve(root, ...rel.split("/"));
1459
1553
  await assertNoSymlink(root, target);
@@ -1463,16 +1557,20 @@ async function saveIcon(path, doc) {
1463
1557
  await rm(tmp, { force: true });
1464
1558
  await writeFile(tmp, bytes, { flag: "wx" });
1465
1559
  await rename(tmp, target);
1560
+ written.push(target);
1466
1561
  }
1467
1562
  const keep = new Set(Object.keys(doc.assets));
1468
1563
  await assertNoSymlink(root, assetsDir);
1469
1564
  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));
1565
+ if (!e.isFile() || keep.has(e.name)) continue;
1566
+ if (e.name.endsWith(".fresnel-tmp") || isSafeBundlePath(`Assets/${e.name}`) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1471
1567
  }
1568
+ return written;
1472
1569
  }
1473
1570
  async function writeFiles(dir, files) {
1474
1571
  const written = [];
1475
1572
  const root = resolve(dir);
1573
+ if ((await lstat(root).catch(() => null))?.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${dir}`);
1476
1574
  for (const [rel, bytes] of Object.entries(files)) {
1477
1575
  const target = resolve(root, ...rel.split("/"));
1478
1576
  const r = relative(root, target);
@@ -1523,7 +1621,7 @@ function contentBoxOf(asset) {
1523
1621
  }
1524
1622
  function svgAssetsNeedingBounds(doc) {
1525
1623
  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);
1624
+ 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
1625
  for (const g of doc.groups)
1528
1626
  for (const l of g.layers) {
1529
1627
  if (!hasGradient(l.props.fill)) continue;
@@ -1552,12 +1650,12 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1552
1650
  }
1553
1651
  var SQUIRCLE = squirclePath();
1554
1652
  var C = CANVAS;
1555
- function maskPath(shape) {
1653
+ function maskPath(shape, pad2 = 0) {
1556
1654
  if (shape === "circle") {
1557
1655
  const r = C / 2 + 32;
1558
1656
  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
1657
  }
1560
- if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
1658
+ if (shape === "none") return `M${-pad2},${-pad2} H${C + pad2} V${C + pad2} H${-pad2}Z`;
1561
1659
  return SQUIRCLE;
1562
1660
  }
1563
1661
 
@@ -1583,6 +1681,17 @@ function hexLuma(hex) {
1583
1681
  const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
1584
1682
  return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
1585
1683
  }
1684
+ var NAMED = { white: 1, black: 0 };
1685
+ function paintLuma(v) {
1686
+ if (v.startsWith("#")) return hexLuma(v);
1687
+ const m = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/i.exec(v);
1688
+ if (m) {
1689
+ const ch = (x) => Math.min(1, Math.max(0, x.endsWith("%") ? Number(x.slice(0, -1)) / 100 : Number(x) / 255));
1690
+ return luminance({ r: ch(m[1]), g: ch(m[2]), b: ch(m[3]), a: 1 });
1691
+ }
1692
+ const named = NAMED[v.toLowerCase()];
1693
+ return named ?? null;
1694
+ }
1586
1695
  function assetLuma(a) {
1587
1696
  const hit = cache2.get(a.bytes);
1588
1697
  if (hit !== void 0) return hit;
@@ -1603,10 +1712,11 @@ function assetLuma(a) {
1603
1712
  }
1604
1713
  } else if (a.kind === "svg") {
1605
1714
  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;
1715
+ const paint = /(?:fill|stop-color)\s*[:=]\s*["']?\s*(rgba?\([^)]*\)|[^"';>\s]+)/gi;
1716
+ const values = [...text2.matchAll(paint)].map((m) => m[1].trim()).filter((v) => !/^(none|transparent|inherit|currentcolor|url\()/i.test(v));
1717
+ const ls = values.map(paintLuma).filter((x) => x !== null);
1718
+ if (ls.length) l = ls.reduce((x, y) => x + y, 0) / ls.length;
1719
+ else l = values.length ? 0.5 : 0;
1610
1720
  }
1611
1721
  cache2.set(a.bytes, l);
1612
1722
  return l;
@@ -1624,6 +1734,7 @@ function rasterRenderOptions(ro) {
1624
1734
  };
1625
1735
  }
1626
1736
  var C2 = CANVAS;
1737
+ var PAD = C2 / 4;
1627
1738
  var H2 = C2 / 2;
1628
1739
  var b64Cache = /* @__PURE__ */ new WeakMap();
1629
1740
  var b64 = (bytes) => {
@@ -1642,11 +1753,32 @@ var b64 = (bytes) => {
1642
1753
  };
1643
1754
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
1644
1755
  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 };
1756
+ var DEFAULT_TINT = { r: 0.25, g: 0.65, b: 0.63, a: 1 };
1757
+ function tintToRGBA(input) {
1758
+ try {
1759
+ const c = parseColor(normalizeColorInput(input));
1760
+ return c ? { ...toRGBA(c), a: 1 } : DEFAULT_TINT;
1761
+ } catch {
1762
+ return DEFAULT_TINT;
1763
+ }
1764
+ }
1765
+ var num2 = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
1766
+ function safePosition(p) {
1767
+ if (!p || typeof p !== "object") return null;
1768
+ const o = p;
1769
+ const t = Array.isArray(o["translation-in-points"]) ? o["translation-in-points"] : [];
1770
+ const scale = num2(o.scale, 1);
1771
+ return { scale: scale > 0 ? scale : 1, tx: num2(t[0], 0), ty: num2(t[1], 0) };
1772
+ }
1773
+ function safeOrientation(o) {
1774
+ const pt = (v) => {
1775
+ const q = v;
1776
+ return q && typeof q === "object" && Number.isFinite(q.x) && Number.isFinite(q.y) ? { x: q.x, y: q.y } : null;
1777
+ };
1778
+ const r = o;
1779
+ const start = r && typeof r === "object" ? pt(r.start) : null;
1780
+ const stop = r && typeof r === "object" ? pt(r.stop) : null;
1781
+ return start && stop ? { start, stop } : void 0;
1650
1782
  }
1651
1783
  var colorOf = (s) => {
1652
1784
  const c = typeof s === "string" ? parseColor(s) : null;
@@ -1693,10 +1825,10 @@ function paintFor(fill, defs, box, darkSystem) {
1693
1825
  const c = colorOf(f["automatic-gradient"]);
1694
1826
  return lin(shade(c, 0.22), shade(c, -0.08));
1695
1827
  }
1696
- if (Array.isArray(f["linear-gradient"])) {
1828
+ if (Array.isArray(f["linear-gradient"]) && f["linear-gradient"].length) {
1697
1829
  const [a, b] = f["linear-gradient"];
1698
- const o = f.orientation;
1699
- return lin(colorOf(a), colorOf(b), o?.start, o?.stop);
1830
+ const o = safeOrientation(f.orientation);
1831
+ return lin(colorOf(a), colorOf(b ?? a), o?.start, o?.stop);
1700
1832
  }
1701
1833
  return null;
1702
1834
  }
@@ -1709,22 +1841,25 @@ function renderSvg(doc, opts = {}) {
1709
1841
  const dark = isDarkAppearance(appearance);
1710
1842
  const isClear = appearance === "clear-light" || appearance === "clear-dark";
1711
1843
  const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
1712
- const tint = hexToRGBA(opts.tint ?? "#3FA7A0");
1844
+ const tint = opts.tint ? tintToRGBA(opts.tint) : DEFAULT_TINT;
1845
+ const idioms = idiomsFor(ctx.idiom);
1846
+ const forPlatform = (s) => s.idiom === void 0 || idioms.includes(s.idiom);
1713
1847
  const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
1714
1848
  const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
1715
1849
  const defs = new Defs(opts.idPrefix ?? "");
1716
1850
  const body = [];
1717
1851
  const fillProp = doc.props.fill;
1718
1852
  let bgFill = resolveProp(fillProp, ctx, "automatic");
1719
- const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark");
1853
+ const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark" && forPlatform(s));
1720
1854
  let darken = 0;
1721
1855
  if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
1722
1856
  let bgPaint;
1723
- const P = mask === "circle" ? 32 : 0;
1857
+ const P = mask === "circle" || mask === "none" && ctx.idiom === "watchOS" ? 32 : 0;
1724
1858
  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);
1859
+ const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" && forPlatform(s));
1726
1860
  if (monoOverride) {
1727
- bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
1861
+ const mono = resolveProp(fillProp, { appearance: dark ? "tinted-dark" : "tinted-light", idiom: ctx.idiom }, "automatic");
1862
+ bgPaint = paintFor(mono ?? "automatic", defs, full, dark);
1728
1863
  } else if (isClear) {
1729
1864
  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
1865
  } else if (isTinted) {
@@ -1765,7 +1900,14 @@ function renderSvg(doc, opts = {}) {
1765
1900
  plainId = plain;
1766
1901
  }
1767
1902
  function drawGroup(g, backdropId) {
1768
- const gpos = resolveProp(g.props.position, ctx, null);
1903
+ const gp = safePosition(resolveProp(g.props.position, ctx, null));
1904
+ const gs = gp?.scale ?? 1;
1905
+ const gtx = gp?.tx ?? 0;
1906
+ const gty = gp?.ty ?? 0;
1907
+ const gx0 = H2 + (-PAD - H2 - gtx) / gs;
1908
+ const gy0 = H2 + (-PAD - H2 - gty) / gs;
1909
+ const gw = (C2 + 2 * PAD) / gs;
1910
+ const localRegion = `x="${n(gx0)}" y="${n(gy0)}" width="${n(gw)}" height="${n(gw)}"`;
1769
1911
  const layers = FRONT_FIRST ? [...g.layers].reverse() : [...g.layers];
1770
1912
  const placed = [];
1771
1913
  for (const l of layers) {
@@ -1773,14 +1915,23 @@ function renderSvg(doc, opts = {}) {
1773
1915
  const name = resolveProp(l.props["image-name"], ctx, "");
1774
1916
  const asset = doc.assets[name];
1775
1917
  if (!asset || asset.kind === "pdf" || asset.kind === "other") continue;
1776
- const pos = resolveProp(l.props.position, ctx, null);
1918
+ if (!(asset.width > 0 && asset.height > 0 && Number.isFinite(asset.width) && Number.isFinite(asset.height))) continue;
1919
+ const pos = safePosition(resolveProp(l.props.position, ctx, null));
1777
1920
  const s = pos?.scale ?? 1;
1778
- const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
1779
1921
  const w = asset.width * s;
1780
1922
  const h = asset.height * s;
1781
- placed.push({ layer: l, asset, x: H2 + tx - w / 2, y: H2 + ty - h / 2, w, h });
1923
+ const x = H2 + (pos?.tx ?? 0) - w / 2;
1924
+ const y = H2 + (pos?.ty ?? 0) - h / 2;
1925
+ const cx0 = H2 + gtx + (x - H2) * gs;
1926
+ const cy0 = H2 + gty + (y - H2) * gs;
1927
+ const cx1 = cx0 + w * gs;
1928
+ const cy1 = cy0 + h * gs;
1929
+ if (cx1 < -P || cy1 < -P || cx0 > C2 + P || cy0 > C2 + P) continue;
1930
+ placed.push({ layer: l, asset, x, y, w, h });
1782
1931
  }
1783
1932
  if (!placed.length) return null;
1933
+ const groupOpacity = Math.min(1, Math.max(0, num2(resolveProp(g.props.opacity, ctx, 1), 1)));
1934
+ if (groupOpacity <= 0) return null;
1784
1935
  const imgId = /* @__PURE__ */ new Map();
1785
1936
  const layerEls = [];
1786
1937
  const glassEls = [];
@@ -1797,7 +1948,7 @@ function renderSvg(doc, opts = {}) {
1797
1948
  const s = p.w / p.asset.width;
1798
1949
  const place = `transform="translate(${n(p.x)} ${n(p.y)}) scale(${n(s)})"`;
1799
1950
  const fill = resolveProp(p.layer.props.fill, ctx, null);
1800
- const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
1951
+ const opacity = Math.min(1, Math.max(0, num2(resolveProp(p.layer.props.opacity, ctx, 1), 1)));
1801
1952
  const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
1802
1953
  const cb = contentBoxOf(p.asset);
1803
1954
  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 +1956,7 @@ function renderSvg(doc, opts = {}) {
1805
1956
  let el;
1806
1957
  if (paint) {
1807
1958
  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>`);
1959
+ defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" ${localRegion}><use href="#${iid}" ${place}/></mask>`);
1809
1960
  el = `<rect x="${n(p.x)}" y="${n(p.y)}" width="${n(p.w)}" height="${n(p.h)}" fill="${paint}" mask="url(#${mid})"/>`;
1810
1961
  } else {
1811
1962
  el = `<use href="#${iid}" ${place}/>`;
@@ -1817,43 +1968,53 @@ function renderSvg(doc, opts = {}) {
1817
1968
  glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
1818
1969
  }
1819
1970
  }
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})"` : "";
1971
+ const gTransform = gp ? ` transform="translate(${n(H2 + gtx)} ${n(H2 + gty)}) scale(${n(gs)}) translate(${-H2} ${-H2})"` : "";
1972
+ const canvasRegion = `filterUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"`;
1973
+ const groupRegion = `filterUnits="userSpaceOnUse" ${localRegion}`;
1821
1974
  const contentId = defs.id("gc");
1822
1975
  defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
1823
1976
  contentIds.push(contentId);
1824
1977
  const out = [];
1825
- const shadow = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
1978
+ const shadow0 = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
1979
+ const shadow = shadow0 && typeof shadow0 === "object" ? { kind: shadow0.kind, opacity: Math.min(1, Math.max(0, num2(shadow0.opacity, 0.5))) } : null;
1826
1980
  if (effects && shadow && shadow.kind !== "none" && shadow.opacity > 0) {
1827
1981
  const fid = defs.id("sh");
1828
1982
  if (shadow.kind === "neutral") {
1829
1983
  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>`
1984
+ `<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
1985
  );
1832
1986
  } else {
1833
1987
  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>`
1988
+ `<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
1989
  );
1836
1990
  }
1837
1991
  out.push(`<use href="#${contentId}" filter="url(#${fid})" opacity="${n(shadow.opacity)}"/>`);
1838
1992
  }
1839
- const tr = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
1840
- const blurMat = resolveProp(g.props["blur-material"], ctx, null);
1993
+ const tr0 = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
1994
+ const tr = tr0 && typeof tr0 === "object" ? { enabled: !!tr0.enabled, value: num2(tr0.value, 0.5) } : null;
1995
+ const blurRaw = resolveProp(g.props["blur-material"], ctx, void 0);
1996
+ const blurMat = blurRaw === null ? null : num2(blurRaw, 0.2);
1841
1997
  const translucent = effects && !!tr?.enabled && tr.value > 0;
1842
- const refr = resolveProp(g.props.refractivity, ctx, null);
1998
+ const refr0 = resolveProp(g.props.refractivity, ctx, null);
1999
+ const refr = refr0 && typeof refr0 === "object" ? { enabled: !!refr0.enabled, strength: num2(refr0.strength, 0), depth: num2(refr0.depth, 0) } : null;
1843
2000
  const refracts = effects && !!refr?.enabled && glassEls.length > 0;
1844
2001
  if (translucent || refracts) {
1845
2002
  const maskSrc = translucent ? `<use href="#${contentId}"/>` : `<g${gTransform}>${glassEls.join("")}</g>`;
1846
2003
  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);
2004
+ 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>`);
2005
+ const blurPx = Math.max(0, (blurMat === null ? 0 : 4 + blurMat * 40) + (refracts ? refr.depth * 10 : 0));
2006
+ let filterAttr = "";
2007
+ if (blurPx > 0) {
2008
+ const fid = defs.id("bk");
2009
+ defs.add(`<filter id="${fid}" ${canvasRegion}><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
2010
+ filterAttr = ` filter="url(#${fid})"`;
2011
+ }
2012
+ const zoom = 1.02 + (refracts ? refr.strength * 0.12 : 0.015);
1852
2013
  out.push(
1853
- `<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
2014
+ `<g mask="url(#${mid})"><use href="#${backdropId}"${filterAttr} transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
1854
2015
  );
1855
2016
  }
1856
- const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
2017
+ const gOpacity = refracts && !translucent ? 1 - Math.min(1, Math.max(0, refr.strength)) * 0.3 : 1;
1857
2018
  let fadeMask = "";
1858
2019
  if (translucent) {
1859
2020
  const v = Math.min(1, Math.max(0, tr.value));
@@ -1879,15 +2040,15 @@ function renderSvg(doc, opts = {}) {
1879
2040
  const fid = defs.id("sp");
1880
2041
  if (specular === "outside") {
1881
2042
  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>`
2043
+ `<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
2044
  );
1884
2045
  } 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>`
2046
+ `<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
2047
  );
1887
2048
  const glass = individual ? glassEls.map((e) => `<g filter="url(#${fid})">${e}</g>`).join("") : `<g filter="url(#${fid})">${glassEls.join("")}</g>`;
1888
2049
  out.push(`<g${gTransform}>${glass}</g>`);
1889
2050
  }
1890
- return out.join("");
2051
+ return groupOpacity < 1 ? `<g opacity="${n(groupOpacity)}">${out.join("")}</g>` : out.join("");
1891
2052
  }
1892
2053
  let top = `<use href="#${stackId}"/>`;
1893
2054
  if ((isClear || isTinted) && contentIds.length) {
@@ -1909,18 +2070,19 @@ function renderSvg(doc, opts = {}) {
1909
2070
  }
1910
2071
  const mid = defs.id("fm");
1911
2072
  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>`
2073
+ `<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
2074
  );
1914
2075
  top = `<use href="#${bgStackId}"/><g filter="url(#${fid})"><use href="#${stackId}" mask="url(#${mid})"/></g>`;
1915
2076
  }
1916
2077
  const clipId = defs.id("clip");
1917
- const shape = maskPath(mask);
2078
+ const shape = maskPath(mask, P);
1918
2079
  defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
1919
- const M = Math.max(0, opts.inset ?? 0);
2080
+ const inset = Math.min(C2 / 2 - 1, Math.max(0, num2(opts.inset, 0)));
2081
+ const M = inset > 0 ? inset * C2 / (C2 - 2 * inset) : 0;
1920
2082
  if (M > 0) {
1921
2083
  const sid = defs.id("ish");
1922
2084
  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>`
2085
+ `<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>`
1924
2086
  );
1925
2087
  body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
1926
2088
  } else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
@@ -1935,8 +2097,9 @@ function renderSvg(doc, opts = {}) {
1935
2097
  }
1936
2098
  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
2099
  }
2100
+ var KNOWN_BLEND = new Set(BLEND_MODES);
1938
2101
  function cssBlend(mode) {
1939
- if (!mode || mode === "normal") return null;
2102
+ if (typeof mode !== "string" || !KNOWN_BLEND.has(mode) || mode === "normal") return null;
1940
2103
  if (mode === "plus-lighter") return "screen";
1941
2104
  if (mode === "plus-darker") return "multiply";
1942
2105
  return mode;
@@ -1979,6 +2142,7 @@ async function resvg() {
1979
2142
  }
1980
2143
  }
1981
2144
  async function svgToPng(svg, width) {
2145
+ if (!(width >= 2)) throw new Error(`Image size must be at least 2 px (got ${width})`);
1982
2146
  const { Resvg } = await resvg();
1983
2147
  const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
1984
2148
  return new Uint8Array(r.render().asPng());
@@ -2107,7 +2271,7 @@ function convert(k, raw, flag) {
2107
2271
  switch (k.type) {
2108
2272
  case "number": {
2109
2273
  const n2 = Number(raw);
2110
- if (raw.trim() === "" || Number.isNaN(n2)) throw new Error(`${flag} expects a number, got "${raw}"`);
2274
+ if (!/^\s*[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?\s*$/i.test(raw) || Number.isNaN(n2)) throw new Error(`${flag} expects a number, got "${raw}"`);
2111
2275
  return n2;
2112
2276
  }
2113
2277
  case "array":
@@ -2122,59 +2286,89 @@ function convert(k, raw, flag) {
2122
2286
  return raw;
2123
2287
  }
2124
2288
  }
2289
+ var BOOL_TRUE = /^(true|1|yes|on)$/i;
2290
+ var BOOL_FALSE = /^(false|0|no|off)$/i;
2125
2291
  function parseToolArgv(t, argv) {
2126
2292
  const infos = argsOf(t);
2127
2293
  const byFlag = new Map(infos.map((a) => [a.flag, a]));
2128
2294
  const out = {};
2129
2295
  let base = {};
2296
+ const roots = [];
2297
+ let help = false;
2130
2298
  const positionals = [];
2299
+ let optionsDone = false;
2131
2300
  for (let i = 0; i < argv.length; i++) {
2132
2301
  const tok = argv[i];
2133
- if (!tok.startsWith("--") || tok === "--") {
2302
+ if (optionsDone || tok === "-" || !tok.startsWith("-")) {
2134
2303
  positionals.push(tok);
2135
2304
  continue;
2136
2305
  }
2306
+ if (tok === "--") {
2307
+ optionsDone = true;
2308
+ continue;
2309
+ }
2310
+ if (tok === "--help" || tok === "-h") {
2311
+ help = true;
2312
+ continue;
2313
+ }
2314
+ if (tok === "-r") {
2315
+ const v2 = argv[++i];
2316
+ if (v2 === void 0) throw new Error("-r needs a directory");
2317
+ roots.push(v2);
2318
+ continue;
2319
+ }
2320
+ if (!tok.startsWith("--")) throw new Error(`Unknown option ${tok}. Run "fresnel ${cliCommand(t)} --help".`);
2137
2321
  const eq = tok.indexOf("=");
2138
2322
  const flag = eq > 0 ? tok.slice(0, eq) : tok;
2139
2323
  const inline = eq > 0 ? tok.slice(eq + 1) : void 0;
2324
+ const value = () => {
2325
+ const raw = inline ?? argv[++i];
2326
+ if (raw === void 0) throw new Error(`${flag} needs a value`);
2327
+ return raw;
2328
+ };
2329
+ if (flag === "--root") {
2330
+ roots.push(value());
2331
+ continue;
2332
+ }
2140
2333
  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;
2334
+ const v2 = convert({ type: "json" }, value(), "--args");
2335
+ if (!v2 || typeof v2 !== "object" || Array.isArray(v2)) throw new Error("--args must be a JSON object");
2336
+ base = { ...base, ...v2 };
2146
2337
  continue;
2147
2338
  }
2148
2339
  const neg = flag.startsWith("--no-") ? byFlag.get(`--${flag.slice(5)}`) : void 0;
2149
- if (neg && neg.kind.type === "boolean") {
2340
+ if (neg && neg.kind.type === "boolean" && inline === void 0) {
2150
2341
  out[neg.name] = false;
2151
2342
  continue;
2152
2343
  }
2153
2344
  const info = byFlag.get(flag);
2154
2345
  if (!info) throw new Error(`Unknown option ${flag} for ${t.id}. Run "fresnel ${cliCommand(t)} --help".`);
2155
2346
  if (info.kind.type === "boolean") {
2156
- out[info.name] = inline === void 0 ? true : !/^(false|0|no)$/i.test(inline);
2347
+ if (inline === void 0) out[info.name] = true;
2348
+ else if (BOOL_TRUE.test(inline)) out[info.name] = true;
2349
+ else if (BOOL_FALSE.test(inline)) out[info.name] = false;
2350
+ else throw new Error(`${flag} expects true or false, got "${inline}"`);
2157
2351
  continue;
2158
2352
  }
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);
2353
+ const v = convert(info.kind, value(), flag);
2354
+ out[info.name] = info.kind.type === "array" && Array.isArray(out[info.name]) ? [...out[info.name], ...v] : v;
2162
2355
  }
2163
2356
  const pos = t.positional ?? [];
2164
2357
  if (positionals.length > pos.length) throw new Error(`Unexpected argument "${positionals[pos.length]}". Run "fresnel ${cliCommand(t)} --help".`);
2165
2358
  positionals.forEach((v, i) => {
2166
2359
  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}>`);
2360
+ if (out[info.name] !== void 0) throw new Error(`<${info.name}> was given twice (as an argument and as ${info.flag})`);
2361
+ out[info.name] = convert(info.kind, v, `<${info.name}>`);
2168
2362
  });
2169
- return { ...base, ...out };
2363
+ return { args: { ...base, ...out }, roots, help };
2170
2364
  }
2171
2365
 
2172
2366
  // ../mcp/src/contract.ts
2173
2367
  import { z as z3 } from "zod";
2174
2368
 
2175
2369
  // ../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";
2370
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
2371
+ import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, sep as sep2 } from "path";
2178
2372
  import { z as z2 } from "zod";
2179
2373
  var CONTRACT_VERSION = "1.0";
2180
2374
  var tool = (def) => def;
@@ -2189,6 +2383,7 @@ var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.arr
2189
2383
  function describeProps(props) {
2190
2384
  const base = {};
2191
2385
  const overrides = {};
2386
+ const variants = /* @__PURE__ */ new Map();
2192
2387
  for (const [k, p] of Object.entries(props)) {
2193
2388
  const b = p.specs?.find((s) => s.appearance === void 0 && s.idiom === void 0);
2194
2389
  if (b) base[k] = b.value;
@@ -2197,21 +2392,32 @@ function describeProps(props) {
2197
2392
  const s = p.specs.find((e) => e.appearance === a && e.idiom === void 0);
2198
2393
  (overrides[a] ??= {})[k] = s.value;
2199
2394
  }
2395
+ for (const s of p.specs ?? []) {
2396
+ if (s.idiom === void 0) continue;
2397
+ const key = `${s.appearance ?? ""}|${s.idiom}`;
2398
+ if (!variants.has(key)) variants.set(key, { ...s.appearance ? { appearance: s.appearance } : {}, platform: s.idiom, values: {} });
2399
+ variants.get(key).values[k] = s.value;
2400
+ }
2200
2401
  }
2201
- return Object.keys(overrides).length ? { ...base, overrides } : base;
2402
+ return {
2403
+ ...base,
2404
+ ...Object.keys(overrides).length ? { overrides } : {},
2405
+ ...variants.size ? { variants: [...variants.values()] } : {}
2406
+ };
2202
2407
  }
2203
2408
  function inspect(doc) {
2204
2409
  return {
2205
2410
  order: "groups and layers are listed front to back (index 0 = frontmost)",
2206
2411
  document: describeProps(doc.props),
2412
+ // attributes first, so an unknown key in the file (e.g. "target") can't replace these fields
2207
2413
  groups: doc.groups.map((g, gi) => ({
2414
+ ...describeProps(g.props),
2208
2415
  target: nameOf(g) || `#${gi}`,
2209
2416
  index: gi,
2210
- ...describeProps(g.props),
2211
2417
  layers: g.layers.map((l, li) => ({
2418
+ ...describeProps(l.props),
2212
2419
  target: `${nameOf(g) || `#${gi}`}/${nameOf(l) || `#${li}`}`,
2213
- index: li,
2214
- ...describeProps(l.props)
2420
+ index: li
2215
2421
  }))
2216
2422
  })),
2217
2423
  assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
@@ -2229,9 +2435,7 @@ function targetLabel(doc, ref) {
2229
2435
  }
2230
2436
  async function saveFile(ws, out, bytes) {
2231
2437
  const p = await ws.resolve(out);
2232
- await mkdir2(dirname2(p), { recursive: true });
2233
- await writeFile2(p, bytes);
2234
- return p;
2438
+ return (await writeFiles(dirname2(p), { [basename(p)]: bytes }))[0];
2235
2439
  }
2236
2440
  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
2441
  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 +2460,9 @@ var TOOLS = [
2256
2460
  },
2257
2461
  output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
2258
2462
  mutates_input: true,
2463
+ writes_files: true,
2259
2464
  idempotency_hint: "not-idempotent",
2465
+ destructive: true,
2260
2466
  supports_dry_run: true,
2261
2467
  verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
2262
2468
  requires_visual_verification: false,
@@ -2264,14 +2470,16 @@ var TOOLS = [
2264
2470
  async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
2265
2471
  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
2472
  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
2473
  const doc = createDocument({
2270
2474
  ...background ? { background } : {},
2271
2475
  platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
2272
2476
  });
2273
2477
  if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
2274
- if (!dry_run) await ws.withLock(full, () => saveIcon(full, doc));
2478
+ await ws.withLock(full, async () => {
2479
+ const exists = await stat2(full).then(() => true, () => false);
2480
+ if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
2481
+ if (!dry_run) await saveIcon(full, doc);
2482
+ });
2275
2483
  return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
2276
2484
  }
2277
2485
  }),
@@ -2287,6 +2495,7 @@ var TOOLS = [
2287
2495
  input: { path: pathArg },
2288
2496
  output: inspectOutput,
2289
2497
  mutates_input: false,
2498
+ writes_files: false,
2290
2499
  idempotency_hint: "idempotent",
2291
2500
  supports_dry_run: false,
2292
2501
  verification: [],
@@ -2315,6 +2524,7 @@ var TOOLS = [
2315
2524
  },
2316
2525
  output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
2317
2526
  mutates_input: true,
2527
+ writes_files: false,
2318
2528
  idempotency_hint: "not-idempotent",
2319
2529
  supports_dry_run: true,
2320
2530
  verification: ["icon_inspect lists the new group"],
@@ -2357,6 +2567,7 @@ var TOOLS = [
2357
2567
  },
2358
2568
  output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
2359
2569
  mutates_input: true,
2570
+ writes_files: false,
2360
2571
  idempotency_hint: "not-idempotent",
2361
2572
  supports_dry_run: true,
2362
2573
  verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
@@ -2432,6 +2643,7 @@ var TOOLS = [
2432
2643
  },
2433
2644
  output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
2434
2645
  mutates_input: true,
2646
+ writes_files: false,
2435
2647
  idempotency_hint: "idempotent",
2436
2648
  supports_dry_run: true,
2437
2649
  verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
@@ -2467,6 +2679,7 @@ var TOOLS = [
2467
2679
  input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
2468
2680
  output: loose({ groups: groupsOutline }),
2469
2681
  mutates_input: true,
2682
+ writes_files: false,
2470
2683
  idempotency_hint: "idempotent",
2471
2684
  supports_dry_run: true,
2472
2685
  verification: ["`groups` lists the new order (front to back)"],
@@ -2480,6 +2693,7 @@ var TOOLS = [
2480
2693
  const ref = resolvePath(doc, target);
2481
2694
  let gid;
2482
2695
  if (to_group) {
2696
+ if (ref.kind !== "layer") throw new Error("to_group only applies to layers; groups are reordered with index");
2483
2697
  const g = resolvePath(doc, to_group);
2484
2698
  if (g.kind !== "group") throw new Error("to_group must be a group");
2485
2699
  gid = g.id;
@@ -2511,6 +2725,7 @@ var TOOLS = [
2511
2725
  },
2512
2726
  output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
2513
2727
  mutates_input: true,
2728
+ writes_files: false,
2514
2729
  idempotency_hint: "idempotent",
2515
2730
  supports_dry_run: true,
2516
2731
  verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
@@ -2546,6 +2761,7 @@ var TOOLS = [
2546
2761
  input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
2547
2762
  output: loose({ removed: z2.string(), groups: groupsOutline }),
2548
2763
  mutates_input: true,
2764
+ writes_files: false,
2549
2765
  idempotency_hint: "not-idempotent",
2550
2766
  destructive: true,
2551
2767
  supports_dry_run: true,
@@ -2582,6 +2798,7 @@ var TOOLS = [
2582
2798
  issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
2583
2799
  }),
2584
2800
  mutates_input: false,
2801
+ writes_files: false,
2585
2802
  idempotency_hint: "idempotent",
2586
2803
  supports_dry_run: false,
2587
2804
  verification: ['No issue with severity "error"'],
@@ -2608,12 +2825,13 @@ var TOOLS = [
2608
2825
  platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
2609
2826
  size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
2610
2827
  mask: maskEnum.optional(),
2611
- tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
2828
+ 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
2829
  light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
2613
2830
  out: z2.string().optional().describe("Save the PNG to this path")
2614
2831
  },
2615
2832
  output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
2616
2833
  mutates_input: false,
2834
+ writes_files: true,
2617
2835
  idempotency_hint: "idempotent",
2618
2836
  supports_dry_run: false,
2619
2837
  verification: ["Look at the returned image"],
@@ -2646,11 +2864,12 @@ var TOOLS = [
2646
2864
  input: {
2647
2865
  path: pathArg,
2648
2866
  cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
2649
- tint: z2.string().optional(),
2867
+ 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
2868
  out: z2.string().optional()
2651
2869
  },
2652
2870
  output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
2653
2871
  mutates_input: false,
2872
+ writes_files: true,
2654
2873
  idempotency_hint: "idempotent",
2655
2874
  supports_dry_run: false,
2656
2875
  verification: ["Look at the returned image: every appearance should stay legible"],
@@ -2676,12 +2895,14 @@ var TOOLS = [
2676
2895
  path: pathArg,
2677
2896
  out_dir: z2.string(),
2678
2897
  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")
2898
+ sizes: z2.array(z2.number().int().min(16).max(2048)).min(1).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
2899
+ appearances: z2.array(appearanceEnum).min(1).optional().describe("PNG appearances, default all six")
2681
2900
  },
2682
2901
  output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
2683
2902
  mutates_input: false,
2903
+ writes_files: true,
2684
2904
  idempotency_hint: "idempotent",
2905
+ destructive: true,
2685
2906
  supports_dry_run: false,
2686
2907
  verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
2687
2908
  requires_visual_verification: false,
@@ -2689,6 +2910,9 @@ var TOOLS = [
2689
2910
  async run(ws, { path, out_dir, formats, sizes, appearances }) {
2690
2911
  const full = await ws.iconPath(path, { mustExist: true });
2691
2912
  const outDir = await ws.resolve(out_dir);
2913
+ const inside = relative2(full, outDir);
2914
+ if (inside === "" || !inside.startsWith(`..${sep2}`) && inside !== ".." && !isAbsolute2(inside))
2915
+ throw new Error("out_dir is inside the icon bundle; choose a folder outside it");
2692
2916
  const doc = await ws.read(full);
2693
2917
  const name = basename(full).replace(/\.icon(\.zip)?$/, "");
2694
2918
  const raster = nodeRasterizer(doc);
@@ -2697,16 +2921,9 @@ var TOOLS = [
2697
2921
  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
2922
  const written = [];
2699
2923
  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);
2924
+ written.push(...await saveIcon(join2(outDir, `${name}.icon`), doc));
2709
2925
  }
2926
+ if (formats.includes("icns")) written.push(...await writeFiles(outDir, { [`${name}.icns`]: await buildIcns(raster) }));
2710
2927
  if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
2711
2928
  if (formats.includes("png"))
2712
2929
  written.push(
@@ -2725,7 +2942,7 @@ async function runTool(ws, t, args) {
2725
2942
  }
2726
2943
 
2727
2944
  // ../mcp/src/version.ts
2728
- var VERSION = "0.2.0";
2945
+ var VERSION = "0.2.2";
2729
2946
 
2730
2947
  // ../mcp/src/contract.ts
2731
2948
  var RESOURCES = [
@@ -2762,6 +2979,7 @@ function toolSpec(t) {
2762
2979
  input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
2763
2980
  output_schema: jsonSchema(t.output, "output"),
2764
2981
  mutates_input: t.mutates_input,
2982
+ writes_files: t.writes_files,
2765
2983
  idempotency_hint: t.idempotency_hint,
2766
2984
  destructive: !!t.destructive,
2767
2985
  supports_dry_run: t.supports_dry_run,
@@ -2794,7 +3012,7 @@ function buildContract() {
2794
3012
  rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
2795
3013
  },
2796
3014
  tools: TOOLS.map(toolSpec),
2797
- capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a.localeCompare(b))),
3015
+ capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
2798
3016
  resources: RESOURCES,
2799
3017
  prompts: PROMPTS,
2800
3018
  icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
@@ -2892,10 +3110,11 @@ function createServer(ws) {
2892
3110
  {
2893
3111
  title: t.title,
2894
3112
  description: t.description,
2895
- inputSchema: t.input,
3113
+ // strict: a misspelt argument (dryRun for dry_run) is an error, not silently dropped
3114
+ inputSchema: z4.object(t.input).strict(),
2896
3115
  outputSchema: t.output,
2897
3116
  annotations: {
2898
- readOnlyHint: !t.mutates_input,
3117
+ readOnlyHint: !t.mutates_input && !t.writes_files,
2899
3118
  destructiveHint: !!t.destructive,
2900
3119
  idempotentHint: t.idempotency_hint === "idempotent",
2901
3120
  openWorldHint: false
@@ -2969,8 +3188,8 @@ Use the fresnel tools:
2969
3188
  }
2970
3189
 
2971
3190
  // ../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";
3191
+ import { lstat as lstat2, realpath, stat as stat3 } from "fs/promises";
3192
+ import { dirname as dirname3, isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
2974
3193
  var Workspace = class {
2975
3194
  roots;
2976
3195
  locks = /* @__PURE__ */ new Map();
@@ -2983,13 +3202,19 @@ var Workspace = class {
2983
3202
  /** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
2984
3203
  async resolve(p, opts = {}) {
2985
3204
  if (!p || typeof p !== "string") throw new Error("A path is required");
2986
- const abs = isAbsolute2(p) ? resolve2(p) : resolve2(this.roots[0], p);
3205
+ const abs = isAbsolute3(p) ? resolve2(p) : resolve2(this.roots[0], p);
2987
3206
  let probe = abs;
2988
3207
  let suffix = "";
2989
3208
  for (; ; ) {
2990
3209
  try {
2991
3210
  const real = await realpath(probe);
2992
3211
  const full = suffix ? resolve2(real, suffix) : real;
3212
+ if (suffix) {
3213
+ const first = resolve2(real, suffix.split(sep3)[0]);
3214
+ const st = await lstat2(first).catch(() => null);
3215
+ if (st?.isSymbolicLink()) throw new Error(`Access denied: ${p} goes through a symbolic link that points nowhere`);
3216
+ if (st) throw new Error(`Invalid path: ${p} (${suffix.split(sep3)[0]} is not a folder)`);
3217
+ }
2993
3218
  if (!await this.allowed(full)) throw new Error(`Access denied: ${p} is outside the allowed roots (${this.roots.join(", ")})`);
2994
3219
  if (opts.mustExist && full !== real) throw new Error(`Not found: ${p}`);
2995
3220
  return full;
@@ -2998,7 +3223,7 @@ var Workspace = class {
2998
3223
  if (opts.mustExist) throw new Error(`Not found: ${p}`);
2999
3224
  const parent = dirname3(probe);
3000
3225
  if (parent === probe) throw new Error(`Invalid path: ${p}`);
3001
- suffix = suffix ? `${probe.slice(parent.length + 1)}${sep2}${suffix}` : probe.slice(parent.length + 1);
3226
+ suffix = suffix ? `${probe.slice(parent.length + 1)}${sep3}${suffix}` : probe.slice(parent.length + 1);
3002
3227
  probe = parent;
3003
3228
  }
3004
3229
  }
@@ -3011,15 +3236,17 @@ var Workspace = class {
3011
3236
  root = await realpath(r);
3012
3237
  } catch {
3013
3238
  }
3014
- const rel = relative2(root, full);
3015
- if (rel === "" || !rel.startsWith("..") && !isAbsolute2(rel)) return true;
3239
+ const rel = relative3(root, full);
3240
+ if (rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute3(rel)) return true;
3016
3241
  }
3017
3242
  return false;
3018
3243
  }
3019
3244
  /** Normalizes an icon path: accepts "Foo", "Foo.icon", ".../Foo.icon/icon.json". */
3020
3245
  async iconPath(p, opts = {}) {
3021
- let q = p.trim();
3246
+ let q = (typeof p === "string" ? p : "").trim().replace(/[\\/]+$/, "");
3022
3247
  if (q.endsWith("/icon.json") || q.endsWith("\\icon.json")) q = q.slice(0, -"/icon.json".length);
3248
+ const base = q.split(/[\\/]/).pop() ?? "";
3249
+ if (!base || base === ".icon" || base === ".icon.zip" || base === "." || base === "..") throw new Error(`Invalid icon path "${p}": give a name such as "MyApp.icon"`);
3023
3250
  if (!q.endsWith(".icon") && !q.endsWith(".icon.zip")) q = `${q}.icon`;
3024
3251
  const full = await this.resolve(q, opts);
3025
3252
  if (opts.mustExist) {
@@ -3081,23 +3308,13 @@ Appearances: ${APPEARANCES.join(", ")}
3081
3308
  Docs: https://github.com/kajisho5/fresnel
3082
3309
  `;
3083
3310
  async function callTool(t, argv) {
3084
- if (argv.includes("--help") || argv.includes("-h")) {
3311
+ const { args, roots, help } = parseToolArgv(t, argv);
3312
+ if (help) {
3085
3313
  process.stdout.write(toolHelp(t));
3086
3314
  return;
3087
3315
  }
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
3316
  const ws = new Workspace(roots, { unrestricted: roots.length === 0 });
3100
- const { data, image } = await runTool(ws, t, parseToolArgv(t, rest));
3317
+ const { data, image } = await runTool(ws, t, args);
3101
3318
  if (image && !data.saved) process.stderr.write("fresnel: the PNG was not saved; pass --out <file.png> to keep it\n");
3102
3319
  process.stdout.write(`${JSON.stringify(data, null, 2)}
3103
3320
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fresnel-icon",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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.2",
42
+ "@fresnel/render": "0.2.2"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsup",