fresnel-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/server.js +171 -45
  2. package/package.json +3 -3
package/dist/server.js CHANGED
@@ -27,6 +27,8 @@ function appearanceKeys(a) {
27
27
  case "tinted-light":
28
28
  case "tinted-dark":
29
29
  return ["tinted"];
30
+ case "default":
31
+ return ["light"];
30
32
  default:
31
33
  return [];
32
34
  }
@@ -279,9 +281,10 @@ var BlendModeSchema = z.enum(BLEND_MODES);
279
281
  var PositionSchema = z.object({ scale: z.number().positive(), "translation-in-points": z.tuple([z.number(), z.number()]) }).loose();
280
282
  var ShadowSchema = z.object({ kind: z.enum(["none", "neutral", "layer-color"]), opacity: z.number().min(0).max(1) }).loose();
281
283
  var TranslucencySchema = z.object({ enabled: z.boolean(), value: z.number().min(0).max(1) }).loose();
282
- var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(0).max(1), depth: z.number().min(0).max(1) }).loose();
284
+ var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(-1).max(1), depth: z.number().min(-1).max(1) }).loose();
283
285
  var SupportedPlatformsSchema = z.object({
284
- squares: z.union([z.literal("shared"), z.array(z.string())]).optional(),
286
+ // "shared", ["iOS"] or {"platforms": ["iOS"]} (both list forms appear in community tools)
287
+ squares: z.union([z.literal("shared"), z.array(z.string()), z.object({ platforms: z.array(z.string()) }).loose()]).optional(),
285
288
  circles: z.array(z.string()).optional()
286
289
  }).loose();
287
290
  var unit = z.number().min(0).max(1);
@@ -425,14 +428,29 @@ var DROP_ELEMENTS = /* @__PURE__ */ new Set([
425
428
  "animatecolor",
426
429
  "discard",
427
430
  "handler",
428
- "listener",
429
- "a"
431
+ "listener"
430
432
  ]);
433
+ var UNWRAP_ELEMENTS = /* @__PURE__ */ new Set(["a"]);
434
+ function decodeForCheck(v) {
435
+ return v.replace(/&#x([0-9a-f]+);?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/&#(\d+);?/g, (_m, d) => String.fromCodePoint(Number(d) % 1114112)).replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1").replace(/\s+/g, "").toLowerCase();
436
+ }
437
+ function expandInternalEntities(svg) {
438
+ const doctype = /<!DOCTYPE[^\[>]*\[([\s\S]*?)\]\s*>/i.exec(svg);
439
+ if (!doctype) return svg;
440
+ const ents = /* @__PURE__ */ new Map();
441
+ for (const m of doctype[1].matchAll(/<!ENTITY\s+([\w.-]+)\s+(?:"([^"]*)"|'([^']*)')\s*>/g)) {
442
+ const v = m[2] ?? m[3] ?? "";
443
+ if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
444
+ }
445
+ const body = svg.slice(0, doctype.index) + svg.slice(doctype.index + doctype[0].length);
446
+ return body.replace(/&([\w.-]+);/g, (m, name) => ents.get(name) ?? m);
447
+ }
431
448
  function safeUrlRef(v) {
432
449
  const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
433
450
  return t.startsWith("#") || /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]*$/i.test(t);
434
451
  }
435
- function cleanCss(css) {
452
+ function cleanCss(css0) {
453
+ const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
436
454
  return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
437
455
  }
438
456
  function cleanAttrs(raw) {
@@ -450,9 +468,13 @@ function cleanAttrs(raw) {
450
468
  if (lname === "href" || lname.endsWith(":href") || lname === "src") {
451
469
  if (!safeUrlRef(value)) continue;
452
470
  }
453
- if (/javascript\s*:|vbscript\s*:/i.test(value)) continue;
471
+ const plain = decodeForCheck(value);
472
+ if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
454
473
  let v = value;
455
- if (lname === "style" || /url\s*\(/i.test(v)) v = cleanCss(v);
474
+ if (lname === "style" || /url\(/.test(plain)) {
475
+ v = cleanCss(v);
476
+ if (/url\((?!['"]?#)/.test(decodeForCheck(v).replace(/url\(['"]?data:image\/(png|jpe?g|gif|webp);base64,/g, "url(#"))) continue;
477
+ }
456
478
  out.push(`${name}="${v.replace(/"/g, "&quot;")}"`);
457
479
  }
458
480
  return out.length ? " " + out.join(" ") : "";
@@ -522,6 +544,7 @@ function sanitizeOnce(svg) {
522
544
  i = j + 1;
523
545
  const [, close, tag, attrs, selfClose] = m;
524
546
  const local = tag.toLowerCase().replace(/^.*:/, "");
547
+ if (UNWRAP_ELEMENTS.has(local) && !skip.length) continue;
525
548
  if (close) {
526
549
  if (skip.length) {
527
550
  if (skip[skip.length - 1] === local) skip.pop();
@@ -546,7 +569,7 @@ function sanitizeOnce(svg) {
546
569
  return out;
547
570
  }
548
571
  function sanitizeSvg(svg) {
549
- let s = svg;
572
+ let s = expandInternalEntities(svg);
550
573
  for (let n2 = 0; n2 < 5; n2++) {
551
574
  const next = sanitizeOnce(s);
552
575
  if (next === s) break;
@@ -565,8 +588,9 @@ function svgFacts(svg) {
565
588
  function makeAsset(name, bytes) {
566
589
  const kind = sniffKind(name, bytes);
567
590
  if (kind === "svg") {
568
- const text2 = sanitizeSvg(td.decode(bytes));
569
- return { name, kind, bytes: te.encode(text2), ...svgSize(text2) };
591
+ const original = td.decode(bytes);
592
+ const text2 = sanitizeSvg(original);
593
+ return { name, kind, bytes, ...text2 !== original ? { display: te.encode(text2) } : {}, ...svgSize(text2) };
570
594
  }
571
595
  if (kind === "png") return { name, kind, bytes, ...pngSize(bytes) };
572
596
  if (kind === "jpg") return { name, kind, bytes, ...jpgSize(bytes) };
@@ -720,7 +744,28 @@ function encodePngRgb(width, height, rgba, bg = [0, 0, 0]) {
720
744
  }
721
745
  function flattenPng(png, bg = [0, 0, 0]) {
722
746
  const bmp = decodePng(png);
723
- return bmp ? encodePngRgb(bmp.width, bmp.height, bmp.data, bg) : png;
747
+ if (!bmp) throw new Error("Could not flatten the PNG (unsupported encoding)");
748
+ return tagSrgb(encodePngRgb(bmp.width, bmp.height, bmp.data, bg));
749
+ }
750
+ function tagSrgb(png) {
751
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
752
+ if (png.length < 33 || sig.some((b, i) => png[i] !== b)) return png;
753
+ let o = 8;
754
+ const dv = new DataView(png.buffer, png.byteOffset, png.byteLength);
755
+ while (o + 8 <= png.length) {
756
+ const len = dv.getUint32(o);
757
+ const type = String.fromCharCode(png[o + 4], png[o + 5], png[o + 6], png[o + 7]);
758
+ if (type === "sRGB" || type === "iCCP") return png;
759
+ if (type === "IDAT") break;
760
+ o += 12 + len;
761
+ }
762
+ const ihdrEnd = 8 + 12 + 13;
763
+ const srgb = chunk("sRGB", new Uint8Array([0]));
764
+ const out = new Uint8Array(png.length + srgb.length);
765
+ out.set(png.subarray(0, ihdrEnd), 0);
766
+ out.set(srgb, ihdrEnd);
767
+ out.set(png.subarray(ihdrEnd), ihdrEnd + srgb.length);
768
+ return out;
724
769
  }
725
770
 
726
771
  // ../core/src/bundle.ts
@@ -757,6 +802,10 @@ function serializeIconJson(doc) {
757
802
  const json = writeProps(doc.props, doc.order, { groups });
758
803
  return JSON.stringify(json, null, 2).replace(/^(\s*"[^"]*"):/gm, "$1 :") + "\n";
759
804
  }
805
+ function isSafeBundlePath(rel) {
806
+ if (!rel || rel.length > 512 || rel.startsWith("/") || /[\\\u0000-\u001f:]/.test(rel)) return false;
807
+ return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
808
+ }
760
809
  function readBundle(files) {
761
810
  const jsonKey = Object.keys(files).find((k) => k === "icon.json" || k.endsWith("/icon.json"));
762
811
  if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
@@ -767,10 +816,11 @@ function readBundle(files) {
767
816
  for (const [k, v] of Object.entries(files)) {
768
817
  if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
769
818
  const rel = k.slice(prefix.length);
819
+ if (!isSafeBundlePath(rel)) continue;
770
820
  if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
771
821
  const name = rel.slice("Assets/".length);
772
- if (name && !name.startsWith(".")) assets[name] = makeAsset(name, v);
773
- } else if (!rel.split("/").some((p) => p === "__MACOSX" || p === ".DS_Store")) {
822
+ if (name) assets[name] = makeAsset(name, v);
823
+ } else if (!rel.split("/").some((p) => p === "__MACOSX")) {
774
824
  extra[rel] = v;
775
825
  }
776
826
  }
@@ -896,12 +946,7 @@ function uniqueName(existing, wanted0) {
896
946
  while (existing.includes(`${wanted} ${i}`)) i++;
897
947
  return `${wanted} ${i}`;
898
948
  }
899
- var MAX_GROUPS = 4;
900
- function assertGroupRoom(doc) {
901
- if (doc.groups.length >= MAX_GROUPS) throw new OpError(`An icon can have up to ${MAX_GROUPS} groups. Add the layer to an existing group instead.`);
902
- }
903
949
  function addGroup(doc, opts = {}) {
904
- assertGroupRoom(doc);
905
950
  const name = uniqueName(doc.groups.map(nameOf), opts.name ?? "Group");
906
951
  const g = { id: newId("g"), props: {}, order: [], layers: [] };
907
952
  setPropValue(g.props, "name", name);
@@ -939,7 +984,8 @@ function addLayer(doc, groupId, asset, opts = {}) {
939
984
  const l = { id: newId("l"), props: {}, order: [] };
940
985
  setPropValue(l.props, "image-name", imageName);
941
986
  setPropValue(l.props, "name", uniqueName(g.layers.map(nameOf), base));
942
- l.order = ["image-name", "name"];
987
+ setPropValue(l.props, "glass", true);
988
+ l.order = ["glass", "image-name", "name"];
943
989
  for (const [k, v] of Object.entries(opts.props ?? {})) setProp(doc, { kind: "layer", id: l.id }, k, v, void 0, l);
944
990
  g.layers.splice(clampIndex(opts.index ?? 0, g.layers.length), 0, l);
945
991
  return l;
@@ -1026,9 +1072,20 @@ var RULES = {
1026
1072
  "V-06": { severity: "info", title: "Very thin strokes may disappear at small sizes" },
1027
1073
  "V-07": { severity: "info", title: "SVG contains text or custom effects" },
1028
1074
  "V-08": { severity: "warning", title: "Appearance is not customized" },
1029
- "V-09": { severity: "error", title: "More than 4 groups" },
1075
+ "V-09": { severity: "warning", title: "More than 4 groups" },
1030
1076
  "V-10": { severity: "error", title: "Unsupported image format" }
1031
1077
  };
1078
+ function fillHasAlpha(fill) {
1079
+ if (!fill || typeof fill !== "object") return false;
1080
+ const f = fill;
1081
+ const colors = [f.solid, f["automatic-gradient"], ...Array.isArray(f["linear-gradient"]) ? f["linear-gradient"] : []];
1082
+ return colors.some((c) => {
1083
+ const p = typeof c === "string" ? parseColor(c) : null;
1084
+ if (!p) return false;
1085
+ const a = p.space === "gray" || p.space === "extended-gray" ? p.c[1] : p.c[3];
1086
+ return a !== void 0 && a < 1;
1087
+ });
1088
+ }
1032
1089
  function validate(doc) {
1033
1090
  const out = [];
1034
1091
  const push = (id, message, target, ref) => out.push({ id, severity: RULES[id].severity, message, target, ...ref ? { ref } : {} });
@@ -1046,8 +1103,9 @@ function validate(doc) {
1046
1103
  checkProps("document", doc.props, "document", { kind: "document" });
1047
1104
  const fill = resolveProp(doc.props.fill, { appearance: "default" }, "automatic");
1048
1105
  if (fill === "none") push("V-05", 'The background fill is "none". Icons need an opaque, full-bleed background.', "document", { kind: "document" });
1106
+ else if (fillHasAlpha(fill)) push("V-05", "The background color is partly transparent. App Store icons are flattened onto black.", "document", { kind: "document" });
1049
1107
  const visibleGroups = doc.groups.filter((g) => resolveProp(g.props.hidden, { appearance: "default" }, false) !== true).length;
1050
- if (visibleGroups > 4) push("V-09", `This icon has ${visibleGroups} visible groups. Icon Composer supports up to 4, and actool rejects icons with more.`, "document");
1108
+ if (visibleGroups > 4) push("V-09", `This icon has ${visibleGroups} visible groups. Apple recommends at most 4, and Xcode's actool may reject icons with more.`, "document");
1051
1109
  let anyDark = false;
1052
1110
  let anyTinted = false;
1053
1111
  const scan = (props) => {
@@ -1151,7 +1209,27 @@ function summarizeIssues(issues) {
1151
1209
 
1152
1210
  // ../core/src/export.ts
1153
1211
  import { strToU8 as strToU82 } from "fflate";
1212
+ function targetsWatch(doc) {
1213
+ const sp = baseValue(doc.props["supported-platforms"]);
1214
+ return Array.isArray(sp?.circles) && sp.circles.includes("watchOS");
1215
+ }
1216
+ function unrenderableLayers(doc) {
1217
+ const out = [];
1218
+ for (const g of doc.groups) {
1219
+ if (baseValue(g.props.hidden) === true) continue;
1220
+ for (const l of g.layers) {
1221
+ if (baseValue(l.props.hidden) === true) continue;
1222
+ const name = baseValue(l.props["image-name"]);
1223
+ const a = typeof name === "string" ? doc.assets[name] : void 0;
1224
+ if (!a || a.kind === "pdf" || a.kind === "other") out.push(`${baseValue(g.props.name) ?? "Group"}/${baseValue(l.props.name) ?? name ?? "Layer"}`);
1225
+ }
1226
+ }
1227
+ return out;
1228
+ }
1229
+ var MAC_INSET = 100;
1154
1230
  var ICNS_TYPES = [
1231
+ ["icp4", 16],
1232
+ ["icp5", 32],
1155
1233
  ["ic11", 32],
1156
1234
  // 16@2x
1157
1235
  ["ic12", 64],
@@ -1190,15 +1268,15 @@ function encodeIcns(pngBySize) {
1190
1268
  }
1191
1269
  async function buildIcns(rasterize, appearance = "default") {
1192
1270
  const pngs = {};
1193
- for (const s of ICNS_SIZES) pngs[s] = await rasterize(appearance, s);
1271
+ for (const s of ICNS_SIZES) pngs[s] = tagSrgb(await rasterize(appearance, s, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
1194
1272
  return encodeIcns(pngs);
1195
1273
  }
1196
- async function buildAppIconSet(rasterize) {
1274
+ async function buildAppIconSet(rasterize, opts = {}) {
1197
1275
  const files = {};
1198
1276
  const images = [];
1199
- files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none" }));
1200
- files["AppIcon-Dark.png"] = flattenPng(await rasterize("dark", 1024, { mask: "none" }));
1201
- files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", tint: "#FFFFFF" }));
1277
+ files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "iOS" }));
1278
+ files["AppIcon-Dark.png"] = tagSrgb(await rasterize("dark", 1024, { mask: "none", idiom: "iOS", background: false }));
1279
+ files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", idiom: "iOS", tint: "#FFFFFF" }));
1202
1280
  images.push({ filename: "AppIcon.png", idiom: "universal", platform: "ios", size: "1024x1024" });
1203
1281
  images.push({
1204
1282
  appearances: [{ appearance: "luminosity", value: "dark" }],
@@ -1218,16 +1296,20 @@ async function buildAppIconSet(rasterize) {
1218
1296
  for (const scale of [1, 2]) {
1219
1297
  const px = pt * scale;
1220
1298
  const name = `mac-${pt}@${scale}x.png`;
1221
- files[name] = await rasterize("default", px, { mask: "squircle" });
1299
+ files[name] = tagSrgb(await rasterize("default", px, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
1222
1300
  images.push({ filename: name, idiom: "mac", scale: `${scale}x`, size: `${pt}x${pt}` });
1223
1301
  }
1224
1302
  }
1303
+ if (opts.watch) {
1304
+ files["AppIcon-Watch.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "watchOS" }));
1305
+ images.push({ filename: "AppIcon-Watch.png", idiom: "universal", platform: "watchos", size: "1024x1024" });
1306
+ }
1225
1307
  files["Contents.json"] = strToU82(JSON.stringify({ images, info: { author: "fresnel", version: 1 } }, null, 2) + "\n");
1226
1308
  return files;
1227
1309
  }
1228
1310
  async function buildPngSet(rasterize, appearances, sizes) {
1229
1311
  const files = {};
1230
- for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = await rasterize(a, s, { mask: "squircle" });
1312
+ for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = tagSrgb(await rasterize(a, s, { mask: "squircle" }));
1231
1313
  return files;
1232
1314
  }
1233
1315
 
@@ -1285,16 +1367,19 @@ async function saveIcon(path, doc) {
1285
1367
  const keep = new Set(Object.keys(doc.assets));
1286
1368
  await assertNoSymlink(root, assetsDir);
1287
1369
  for (const e of await readdir(assetsDir, { withFileTypes: true })) {
1288
- if (e.isFile() && !keep.has(e.name) && e.name !== ".DS_Store") await rm(join(assetsDir, e.name));
1370
+ if (e.isFile() && !keep.has(e.name) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1289
1371
  }
1290
1372
  }
1291
1373
  async function writeFiles(dir, files) {
1292
1374
  const written = [];
1375
+ const root = resolve(dir);
1293
1376
  for (const [rel, bytes] of Object.entries(files)) {
1294
- const target = resolve(dir, ...rel.split("/"));
1295
- const r = relative(resolve(dir), target);
1377
+ const target = resolve(root, ...rel.split("/"));
1378
+ const r = relative(root, target);
1296
1379
  if (r.startsWith("..") || isAbsolute(r)) throw new Error(`Refusing to write outside ${dir}: ${rel}`);
1380
+ await assertNoSymlink(root, target);
1297
1381
  await mkdir(dirname(target), { recursive: true });
1382
+ await assertNoSymlink(root, target);
1298
1383
  await writeFile(target, bytes);
1299
1384
  written.push(target);
1300
1385
  }
@@ -1318,12 +1403,25 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1318
1403
  var SQUIRCLE = squirclePath();
1319
1404
  var C = CANVAS;
1320
1405
  function maskPath(shape) {
1321
- if (shape === "circle") return `M${C / 2},0 A${C / 2},${C / 2} 0 1 1 ${C / 2},${C} A${C / 2},${C / 2} 0 1 1 ${C / 2},0Z`;
1406
+ if (shape === "circle") {
1407
+ const r = C / 2 + 32;
1408
+ 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`;
1409
+ }
1322
1410
  if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
1323
1411
  return SQUIRCLE;
1324
1412
  }
1325
1413
 
1326
1414
  // ../render/src/compose.ts
1415
+ function rasterRenderOptions(ro) {
1416
+ if (!ro) return {};
1417
+ return {
1418
+ ...ro.mask ? { mask: ro.mask, rim: ro.mask !== "none" } : {},
1419
+ ...ro.tint ? { tint: ro.tint } : {},
1420
+ ...ro.idiom ? { idiom: ro.idiom } : {},
1421
+ ...ro.background === false ? { background: false } : {},
1422
+ ...ro.inset ? { inset: ro.inset } : {}
1423
+ };
1424
+ }
1327
1425
  var C2 = CANVAS;
1328
1426
  var H = C2 / 2;
1329
1427
  var b64Cache = /* @__PURE__ */ new WeakMap();
@@ -1421,7 +1519,8 @@ function renderSvg(doc, opts = {}) {
1421
1519
  let darken = 0;
1422
1520
  if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
1423
1521
  let bgPaint;
1424
- const full = { x: 0, y: 0, w: C2, h: C2 };
1522
+ const P = mask === "circle" ? 32 : 0;
1523
+ const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
1425
1524
  const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" || s.appearance === appearance);
1426
1525
  if (monoOverride) {
1427
1526
  bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
@@ -1440,12 +1539,16 @@ function renderSvg(doc, opts = {}) {
1440
1539
  if (bgFill === void 0) bgFill = "automatic";
1441
1540
  bgPaint = paintFor(bgFill, defs, full, dark);
1442
1541
  }
1542
+ if (opts.background === false) {
1543
+ bgPaint = null;
1544
+ darken = 0;
1545
+ }
1443
1546
  const groups = FRONT_FIRST ? [...doc.groups].reverse() : [...doc.groups];
1444
1547
  let stackId = defs.id("s");
1445
1548
  const bgStackId = stackId;
1446
1549
  const contentIds = [];
1447
1550
  defs.add(
1448
- `<g id="${stackId}">${bgPaint ? `<rect width="${C2}" height="${C2}" fill="${bgPaint}"/>` : ""}` + (darken ? `<rect width="${C2}" height="${C2}" fill="#000" opacity="${darken}"/>` : "") + `</g>`
1551
+ `<g id="${stackId}">${bgPaint ? `<rect x="${-P}" y="${-P}" width="${C2 + 2 * P}" height="${C2 + 2 * P}" fill="${bgPaint}"/>` : ""}` + (darken ? `<rect x="${-P}" y="${-P}" width="${C2 + 2 * P}" height="${C2 + 2 * P}" fill="#000" opacity="${darken}"/>` : "") + `</g>`
1449
1552
  );
1450
1553
  let plainId = bgStackId;
1451
1554
  for (const g of groups) {
@@ -1485,7 +1588,7 @@ function renderSvg(doc, opts = {}) {
1485
1588
  iid = defs.id("img");
1486
1589
  imgId.set(p.asset.name, iid);
1487
1590
  defs.add(
1488
- `<image id="${iid}" width="${n(p.asset.width)}" height="${n(p.asset.height)}" preserveAspectRatio="none" href="data:${mimeOf(p.asset)};base64,${b64(p.asset.bytes)}"/>`
1591
+ `<image id="${iid}" width="${n(p.asset.width)}" height="${n(p.asset.height)}" preserveAspectRatio="none" href="data:${mimeOf(p.asset)};base64,${b64(p.asset.display ?? p.asset.bytes)}"/>`
1489
1592
  );
1490
1593
  }
1491
1594
  const s = p.w / p.asset.width;
@@ -1542,9 +1645,21 @@ function renderSvg(doc, opts = {}) {
1542
1645
  `<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H} ${H}) scale(${n(zoom)}) translate(${-H} ${-H})"/></g>`
1543
1646
  );
1544
1647
  }
1545
- const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (translucent ? 1 - tr.value * 0.55 : 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
1648
+ const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
1649
+ let fadeMask = "";
1650
+ if (translucent) {
1651
+ const v = Math.min(1, Math.max(0, tr.value));
1652
+ const gid = defs.id("tg");
1653
+ const mid = defs.id("tm");
1654
+ defs.add(
1655
+ `<linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity="${n(1 - v * 0.2)}"/><stop offset="1" stop-color="#fff" stop-opacity="${n(1 - v * 0.85)}"/></linearGradient><mask id="${mid}" maskUnits="objectBoundingBox" maskContentUnits="objectBoundingBox" x="0" y="0" width="1" height="1"><rect width="1" height="1" fill="url(#${gid})"/></mask>`
1656
+ );
1657
+ fadeMask = ` mask="url(#${mid})"`;
1658
+ }
1546
1659
  const gBlend = cssBlend(resolveProp(g.props["blend-mode"], ctx, "normal"));
1547
- out.push(`<use href="#${contentId}"${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}/>`);
1660
+ out.push(
1661
+ `<g${fadeMask}${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}><use href="#${contentId}"/></g>`
1662
+ );
1548
1663
  const specular = resolveProp(g.props.specular, ctx, true);
1549
1664
  if (effects && specular && glassEls.length) {
1550
1665
  const k = 7;
@@ -1591,7 +1706,14 @@ function renderSvg(doc, opts = {}) {
1591
1706
  const clipId = defs.id("clip");
1592
1707
  const shape = maskPath(mask);
1593
1708
  defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
1594
- body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
1709
+ const M = Math.max(0, opts.inset ?? 0);
1710
+ if (M > 0) {
1711
+ const sid = defs.id("ish");
1712
+ defs.add(
1713
+ `<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>`
1714
+ );
1715
+ body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
1716
+ } else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
1595
1717
  if (effects && opts.rim !== false && mask !== "none") {
1596
1718
  const rid = defs.id("rim");
1597
1719
  const lx = H + toLight.x * H;
@@ -1601,7 +1723,7 @@ function renderSvg(doc, opts = {}) {
1601
1723
  );
1602
1724
  body.push(`<g clip-path="url(#${clipId})"><path d="${shape}" fill="none" stroke="url(#${rid})" stroke-width="10"/></g>`);
1603
1725
  }
1604
- return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${size}" height="${size}" viewBox="0 0 ${C2} ${C2}"><title>${esc(appearance)}</title><defs>${defs.toString()}</defs>${body.join("")}</svg>`;
1726
+ 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>`;
1605
1727
  }
1606
1728
  function cssBlend(mode) {
1607
1729
  if (!mode || mode === "normal") return null;
@@ -1660,7 +1782,7 @@ async function renderSheetPng(doc, opts = {}) {
1660
1782
  return svgToPng(renderSheetSvg(doc, opts), cell * 3);
1661
1783
  }
1662
1784
  function nodeRasterizer(doc, opts = {}) {
1663
- return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...ro?.mask ? { mask: ro.mask, rim: ro.mask !== "none" } : {}, ...ro?.tint ? { tint: ro.tint } : {} });
1785
+ return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...rasterRenderOptions(ro) });
1664
1786
  }
1665
1787
 
1666
1788
  // src/guide.ts
@@ -1673,7 +1795,7 @@ https://developer.apple.com/design/human-interface-guidelines/app-icons
1673
1795
  - An icon is a background plus one or more foreground layers. The system adds Liquid Glass effects (specular highlights, refraction, translucency) and adapts them to size and system version.
1674
1796
  - Canvas: 1024\xD71024 for iOS/iPadOS/macOS (masked to a rounded rectangle) and 1088\xD71088 for watchOS (masked to a circle).
1675
1797
  - Appearances: Default, Dark, Clear light, Clear dark, Tinted light, Tinted dark. If you don't provide a variant, the system generates one.
1676
- - Groups: Liquid Glass effects (mode, specular Off/Automatic/Inside/Outside, blur, refraction, translucency, shadow) are configured per group. Use at most 4 groups \u2014 actool rejects icons with more.
1798
+ - Groups: Liquid Glass effects (mode, specular Off/Automatic/Inside/Outside, blur, refraction, translucency, shadow) are configured per group. Use at most 4 groups (Apple's recommendation; some actool versions reject more).
1677
1799
  - In Icon Composer the appearances are Default, Dark and Mono (Mono covers clear and tinted); icon.json stores Mono overrides under the "tinted" key.
1678
1800
 
1679
1801
  ## Layers
@@ -1733,7 +1855,7 @@ ${table("Layer", LAYER_ATTRS)}
1733
1855
  `;
1734
1856
 
1735
1857
  // src/tools.ts
1736
- var VERSION = "0.1.0";
1858
+ var VERSION = "0.1.1";
1737
1859
  var appearanceEnum = z2.enum(APPEARANCES);
1738
1860
  var overrideEnum = z2.enum(["dark", "tinted"]);
1739
1861
  var maskEnum = z2.enum(["squircle", "circle", "none"]);
@@ -2072,10 +2194,14 @@ function createServer(ws) {
2072
2194
  const doc = await ws.read(full);
2073
2195
  const name = basename(full).replace(/\.icon(\.zip)?$/, "");
2074
2196
  const raster = nodeRasterizer(doc);
2197
+ const missing = unrenderableLayers(doc);
2198
+ if (missing.length && formats.some((f) => f !== "icon"))
2199
+ throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them first (icon_validate).`);
2075
2200
  const written = [];
2076
2201
  if (formats.includes("icon")) {
2077
- const files = writeBundle(doc, doc.extra);
2078
- written.push(...await writeFiles(join2(outDir, `${name}.icon`), files));
2202
+ const dest = join2(outDir, `${name}.icon`);
2203
+ await saveIcon(dest, doc);
2204
+ written.push(join2(dest, "icon.json"), ...Object.keys(doc.assets).map((a) => join2(dest, "Assets", a)));
2079
2205
  }
2080
2206
  if (formats.includes("icns")) {
2081
2207
  const p = join2(outDir, `${name}.icns`);
@@ -2083,7 +2209,7 @@ function createServer(ws) {
2083
2209
  await writeFile2(p, await buildIcns(raster));
2084
2210
  written.push(p);
2085
2211
  }
2086
- if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster)));
2212
+ if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
2087
2213
  if (formats.includes("png"))
2088
2214
  written.push(
2089
2215
  ...await writeFiles(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fresnel-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server for fresnel: let AI agents create, edit, preview and export Liquid Glass app icons (.icon bundles)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,8 +38,8 @@
38
38
  "devDependencies": {
39
39
  "tsup": "9.0.0",
40
40
  "tsx": "4.23.15",
41
- "@fresnel/core": "0.1.0",
42
- "@fresnel/render": "0.1.0"
41
+ "@fresnel/core": "0.1.1",
42
+ "@fresnel/render": "0.1.1"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsup",