fresnel-icon 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/cli.js +181 -49
  2. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -23,6 +23,8 @@ function appearanceKeys(a) {
23
23
  case "tinted-light":
24
24
  case "tinted-dark":
25
25
  return ["tinted"];
26
+ case "default":
27
+ return ["light"];
26
28
  default:
27
29
  return [];
28
30
  }
@@ -275,9 +277,10 @@ var BlendModeSchema = z.enum(BLEND_MODES);
275
277
  var PositionSchema = z.object({ scale: z.number().positive(), "translation-in-points": z.tuple([z.number(), z.number()]) }).loose();
276
278
  var ShadowSchema = z.object({ kind: z.enum(["none", "neutral", "layer-color"]), opacity: z.number().min(0).max(1) }).loose();
277
279
  var TranslucencySchema = z.object({ enabled: z.boolean(), value: z.number().min(0).max(1) }).loose();
278
- var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(0).max(1), depth: z.number().min(0).max(1) }).loose();
280
+ var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(-1).max(1), depth: z.number().min(-1).max(1) }).loose();
279
281
  var SupportedPlatformsSchema = z.object({
280
- squares: z.union([z.literal("shared"), z.array(z.string())]).optional(),
282
+ // "shared", ["iOS"] or {"platforms": ["iOS"]} (both list forms appear in community tools)
283
+ squares: z.union([z.literal("shared"), z.array(z.string()), z.object({ platforms: z.array(z.string()) }).loose()]).optional(),
281
284
  circles: z.array(z.string()).optional()
282
285
  }).loose();
283
286
  var unit = z.number().min(0).max(1);
@@ -421,14 +424,29 @@ var DROP_ELEMENTS = /* @__PURE__ */ new Set([
421
424
  "animatecolor",
422
425
  "discard",
423
426
  "handler",
424
- "listener",
425
- "a"
427
+ "listener"
426
428
  ]);
429
+ var UNWRAP_ELEMENTS = /* @__PURE__ */ new Set(["a"]);
430
+ function decodeForCheck(v) {
431
+ 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();
432
+ }
433
+ function expandInternalEntities(svg) {
434
+ const doctype = /<!DOCTYPE[^\[>]*\[([\s\S]*?)\]\s*>/i.exec(svg);
435
+ if (!doctype) return svg;
436
+ const ents = /* @__PURE__ */ new Map();
437
+ for (const m of doctype[1].matchAll(/<!ENTITY\s+([\w.-]+)\s+(?:"([^"]*)"|'([^']*)')\s*>/g)) {
438
+ const v = m[2] ?? m[3] ?? "";
439
+ if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
440
+ }
441
+ const body = svg.slice(0, doctype.index) + svg.slice(doctype.index + doctype[0].length);
442
+ return body.replace(/&([\w.-]+);/g, (m, name) => ents.get(name) ?? m);
443
+ }
427
444
  function safeUrlRef(v) {
428
445
  const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
429
446
  return t.startsWith("#") || /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]*$/i.test(t);
430
447
  }
431
- function cleanCss(css) {
448
+ function cleanCss(css0) {
449
+ const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
432
450
  return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
433
451
  }
434
452
  function cleanAttrs(raw) {
@@ -446,9 +464,13 @@ function cleanAttrs(raw) {
446
464
  if (lname === "href" || lname.endsWith(":href") || lname === "src") {
447
465
  if (!safeUrlRef(value)) continue;
448
466
  }
449
- if (/javascript\s*:|vbscript\s*:/i.test(value)) continue;
467
+ const plain = decodeForCheck(value);
468
+ if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
450
469
  let v = value;
451
- if (lname === "style" || /url\s*\(/i.test(v)) v = cleanCss(v);
470
+ if (lname === "style" || /url\(/.test(plain)) {
471
+ v = cleanCss(v);
472
+ if (/url\((?!['"]?#)/.test(decodeForCheck(v).replace(/url\(['"]?data:image\/(png|jpe?g|gif|webp);base64,/g, "url(#"))) continue;
473
+ }
452
474
  out.push(`${name}="${v.replace(/"/g, "&quot;")}"`);
453
475
  }
454
476
  return out.length ? " " + out.join(" ") : "";
@@ -518,6 +540,7 @@ function sanitizeOnce(svg) {
518
540
  i = j + 1;
519
541
  const [, close, tag, attrs, selfClose] = m;
520
542
  const local = tag.toLowerCase().replace(/^.*:/, "");
543
+ if (UNWRAP_ELEMENTS.has(local) && !skip.length) continue;
521
544
  if (close) {
522
545
  if (skip.length) {
523
546
  if (skip[skip.length - 1] === local) skip.pop();
@@ -542,7 +565,7 @@ function sanitizeOnce(svg) {
542
565
  return out;
543
566
  }
544
567
  function sanitizeSvg(svg) {
545
- let s = svg;
568
+ let s = expandInternalEntities(svg);
546
569
  for (let n2 = 0; n2 < 5; n2++) {
547
570
  const next = sanitizeOnce(s);
548
571
  if (next === s) break;
@@ -561,8 +584,9 @@ function svgFacts(svg) {
561
584
  function makeAsset(name, bytes) {
562
585
  const kind = sniffKind(name, bytes);
563
586
  if (kind === "svg") {
564
- const text2 = sanitizeSvg(td.decode(bytes));
565
- return { name, kind, bytes: te.encode(text2), ...svgSize(text2) };
587
+ const original = td.decode(bytes);
588
+ const text2 = sanitizeSvg(original);
589
+ return { name, kind, bytes, ...text2 !== original ? { display: te.encode(text2) } : {}, ...svgSize(text2) };
566
590
  }
567
591
  if (kind === "png") return { name, kind, bytes, ...pngSize(bytes) };
568
592
  if (kind === "jpg") return { name, kind, bytes, ...jpgSize(bytes) };
@@ -716,7 +740,28 @@ function encodePngRgb(width, height, rgba, bg = [0, 0, 0]) {
716
740
  }
717
741
  function flattenPng(png, bg = [0, 0, 0]) {
718
742
  const bmp = decodePng(png);
719
- return bmp ? encodePngRgb(bmp.width, bmp.height, bmp.data, bg) : png;
743
+ if (!bmp) throw new Error("Could not flatten the PNG (unsupported encoding)");
744
+ return tagSrgb(encodePngRgb(bmp.width, bmp.height, bmp.data, bg));
745
+ }
746
+ function tagSrgb(png) {
747
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
748
+ if (png.length < 33 || sig.some((b, i) => png[i] !== b)) return png;
749
+ let o = 8;
750
+ const dv = new DataView(png.buffer, png.byteOffset, png.byteLength);
751
+ while (o + 8 <= png.length) {
752
+ const len = dv.getUint32(o);
753
+ const type = String.fromCharCode(png[o + 4], png[o + 5], png[o + 6], png[o + 7]);
754
+ if (type === "sRGB" || type === "iCCP") return png;
755
+ if (type === "IDAT") break;
756
+ o += 12 + len;
757
+ }
758
+ const ihdrEnd = 8 + 12 + 13;
759
+ const srgb = chunk("sRGB", new Uint8Array([0]));
760
+ const out = new Uint8Array(png.length + srgb.length);
761
+ out.set(png.subarray(0, ihdrEnd), 0);
762
+ out.set(srgb, ihdrEnd);
763
+ out.set(png.subarray(ihdrEnd), ihdrEnd + srgb.length);
764
+ return out;
720
765
  }
721
766
 
722
767
  // ../core/src/bundle.ts
@@ -753,6 +798,10 @@ function serializeIconJson(doc) {
753
798
  const json = writeProps(doc.props, doc.order, { groups });
754
799
  return JSON.stringify(json, null, 2).replace(/^(\s*"[^"]*"):/gm, "$1 :") + "\n";
755
800
  }
801
+ function isSafeBundlePath(rel) {
802
+ if (!rel || rel.length > 512 || rel.startsWith("/") || /[\\\u0000-\u001f:]/.test(rel)) return false;
803
+ return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
804
+ }
756
805
  function readBundle(files) {
757
806
  const jsonKey = Object.keys(files).find((k) => k === "icon.json" || k.endsWith("/icon.json"));
758
807
  if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
@@ -763,10 +812,11 @@ function readBundle(files) {
763
812
  for (const [k, v] of Object.entries(files)) {
764
813
  if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
765
814
  const rel = k.slice(prefix.length);
815
+ if (!isSafeBundlePath(rel)) continue;
766
816
  if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
767
817
  const name = rel.slice("Assets/".length);
768
- if (name && !name.startsWith(".")) assets[name] = makeAsset(name, v);
769
- } else if (!rel.split("/").some((p) => p === "__MACOSX" || p === ".DS_Store")) {
818
+ if (name) assets[name] = makeAsset(name, v);
819
+ } else if (!rel.split("/").some((p) => p === "__MACOSX")) {
770
820
  extra[rel] = v;
771
821
  }
772
822
  }
@@ -892,12 +942,7 @@ function uniqueName(existing, wanted0) {
892
942
  while (existing.includes(`${wanted} ${i}`)) i++;
893
943
  return `${wanted} ${i}`;
894
944
  }
895
- var MAX_GROUPS = 4;
896
- function assertGroupRoom(doc) {
897
- 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.`);
898
- }
899
945
  function addGroup(doc, opts = {}) {
900
- assertGroupRoom(doc);
901
946
  const name = uniqueName(doc.groups.map(nameOf), opts.name ?? "Group");
902
947
  const g = { id: newId("g"), props: {}, order: [], layers: [] };
903
948
  setPropValue(g.props, "name", name);
@@ -935,7 +980,8 @@ function addLayer(doc, groupId, asset, opts = {}) {
935
980
  const l = { id: newId("l"), props: {}, order: [] };
936
981
  setPropValue(l.props, "image-name", imageName);
937
982
  setPropValue(l.props, "name", uniqueName(g.layers.map(nameOf), base));
938
- l.order = ["image-name", "name"];
983
+ setPropValue(l.props, "glass", true);
984
+ l.order = ["glass", "image-name", "name"];
939
985
  for (const [k, v] of Object.entries(opts.props ?? {})) setProp(doc, { kind: "layer", id: l.id }, k, v, void 0, l);
940
986
  g.layers.splice(clampIndex(opts.index ?? 0, g.layers.length), 0, l);
941
987
  return l;
@@ -1022,9 +1068,20 @@ var RULES = {
1022
1068
  "V-06": { severity: "info", title: "Very thin strokes may disappear at small sizes" },
1023
1069
  "V-07": { severity: "info", title: "SVG contains text or custom effects" },
1024
1070
  "V-08": { severity: "warning", title: "Appearance is not customized" },
1025
- "V-09": { severity: "error", title: "More than 4 groups" },
1071
+ "V-09": { severity: "warning", title: "More than 4 groups" },
1026
1072
  "V-10": { severity: "error", title: "Unsupported image format" }
1027
1073
  };
1074
+ function fillHasAlpha(fill) {
1075
+ if (!fill || typeof fill !== "object") return false;
1076
+ const f = fill;
1077
+ const colors = [f.solid, f["automatic-gradient"], ...Array.isArray(f["linear-gradient"]) ? f["linear-gradient"] : []];
1078
+ return colors.some((c) => {
1079
+ const p = typeof c === "string" ? parseColor(c) : null;
1080
+ if (!p) return false;
1081
+ const a = p.space === "gray" || p.space === "extended-gray" ? p.c[1] : p.c[3];
1082
+ return a !== void 0 && a < 1;
1083
+ });
1084
+ }
1028
1085
  function validate(doc) {
1029
1086
  const out = [];
1030
1087
  const push = (id, message, target, ref) => out.push({ id, severity: RULES[id].severity, message, target, ...ref ? { ref } : {} });
@@ -1042,8 +1099,9 @@ function validate(doc) {
1042
1099
  checkProps("document", doc.props, "document", { kind: "document" });
1043
1100
  const fill = resolveProp(doc.props.fill, { appearance: "default" }, "automatic");
1044
1101
  if (fill === "none") push("V-05", 'The background fill is "none". Icons need an opaque, full-bleed background.', "document", { kind: "document" });
1102
+ else if (fillHasAlpha(fill)) push("V-05", "The background color is partly transparent. App Store icons are flattened onto black.", "document", { kind: "document" });
1045
1103
  const visibleGroups = doc.groups.filter((g) => resolveProp(g.props.hidden, { appearance: "default" }, false) !== true).length;
1046
- 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");
1104
+ 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");
1047
1105
  let anyDark = false;
1048
1106
  let anyTinted = false;
1049
1107
  const scan = (props) => {
@@ -1147,7 +1205,27 @@ function summarizeIssues(issues) {
1147
1205
 
1148
1206
  // ../core/src/export.ts
1149
1207
  import { strToU8 as strToU82 } from "fflate";
1208
+ function targetsWatch(doc) {
1209
+ const sp = baseValue(doc.props["supported-platforms"]);
1210
+ return Array.isArray(sp?.circles) && sp.circles.includes("watchOS");
1211
+ }
1212
+ function unrenderableLayers(doc) {
1213
+ const out = [];
1214
+ for (const g of doc.groups) {
1215
+ if (baseValue(g.props.hidden) === true) continue;
1216
+ for (const l of g.layers) {
1217
+ if (baseValue(l.props.hidden) === true) continue;
1218
+ const name = baseValue(l.props["image-name"]);
1219
+ const a = typeof name === "string" ? doc.assets[name] : void 0;
1220
+ if (!a || a.kind === "pdf" || a.kind === "other") out.push(`${baseValue(g.props.name) ?? "Group"}/${baseValue(l.props.name) ?? name ?? "Layer"}`);
1221
+ }
1222
+ }
1223
+ return out;
1224
+ }
1225
+ var MAC_INSET = 100;
1150
1226
  var ICNS_TYPES = [
1227
+ ["icp4", 16],
1228
+ ["icp5", 32],
1151
1229
  ["ic11", 32],
1152
1230
  // 16@2x
1153
1231
  ["ic12", 64],
@@ -1186,15 +1264,15 @@ function encodeIcns(pngBySize) {
1186
1264
  }
1187
1265
  async function buildIcns(rasterize, appearance = "default") {
1188
1266
  const pngs = {};
1189
- for (const s of ICNS_SIZES) pngs[s] = await rasterize(appearance, s);
1267
+ for (const s of ICNS_SIZES) pngs[s] = tagSrgb(await rasterize(appearance, s, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
1190
1268
  return encodeIcns(pngs);
1191
1269
  }
1192
- async function buildAppIconSet(rasterize) {
1270
+ async function buildAppIconSet(rasterize, opts = {}) {
1193
1271
  const files = {};
1194
1272
  const images = [];
1195
- files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none" }));
1196
- files["AppIcon-Dark.png"] = flattenPng(await rasterize("dark", 1024, { mask: "none" }));
1197
- files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", tint: "#FFFFFF" }));
1273
+ files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "iOS" }));
1274
+ files["AppIcon-Dark.png"] = tagSrgb(await rasterize("dark", 1024, { mask: "none", idiom: "iOS", background: false }));
1275
+ files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", idiom: "iOS", tint: "#FFFFFF" }));
1198
1276
  images.push({ filename: "AppIcon.png", idiom: "universal", platform: "ios", size: "1024x1024" });
1199
1277
  images.push({
1200
1278
  appearances: [{ appearance: "luminosity", value: "dark" }],
@@ -1214,16 +1292,20 @@ async function buildAppIconSet(rasterize) {
1214
1292
  for (const scale of [1, 2]) {
1215
1293
  const px = pt * scale;
1216
1294
  const name = `mac-${pt}@${scale}x.png`;
1217
- files[name] = await rasterize("default", px, { mask: "squircle" });
1295
+ files[name] = tagSrgb(await rasterize("default", px, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
1218
1296
  images.push({ filename: name, idiom: "mac", scale: `${scale}x`, size: `${pt}x${pt}` });
1219
1297
  }
1220
1298
  }
1299
+ if (opts.watch) {
1300
+ files["AppIcon-Watch.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "watchOS" }));
1301
+ images.push({ filename: "AppIcon-Watch.png", idiom: "universal", platform: "watchos", size: "1024x1024" });
1302
+ }
1221
1303
  files["Contents.json"] = strToU82(JSON.stringify({ images, info: { author: "fresnel", version: 1 } }, null, 2) + "\n");
1222
1304
  return files;
1223
1305
  }
1224
1306
  async function buildPngSet(rasterize, appearances, sizes) {
1225
1307
  const files = {};
1226
- for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = await rasterize(a, s, { mask: "squircle" });
1308
+ for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = tagSrgb(await rasterize(a, s, { mask: "squircle" }));
1227
1309
  return files;
1228
1310
  }
1229
1311
 
@@ -1281,16 +1363,19 @@ async function saveIcon(path, doc) {
1281
1363
  const keep = new Set(Object.keys(doc.assets));
1282
1364
  await assertNoSymlink(root, assetsDir);
1283
1365
  for (const e of await readdir(assetsDir, { withFileTypes: true })) {
1284
- if (e.isFile() && !keep.has(e.name) && e.name !== ".DS_Store") await rm(join(assetsDir, e.name));
1366
+ if (e.isFile() && !keep.has(e.name) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
1285
1367
  }
1286
1368
  }
1287
1369
  async function writeFiles(dir, files) {
1288
1370
  const written = [];
1371
+ const root = resolve(dir);
1289
1372
  for (const [rel, bytes] of Object.entries(files)) {
1290
- const target = resolve(dir, ...rel.split("/"));
1291
- const r = relative(resolve(dir), target);
1373
+ const target = resolve(root, ...rel.split("/"));
1374
+ const r = relative(root, target);
1292
1375
  if (r.startsWith("..") || isAbsolute(r)) throw new Error(`Refusing to write outside ${dir}: ${rel}`);
1376
+ await assertNoSymlink(root, target);
1293
1377
  await mkdir(dirname(target), { recursive: true });
1378
+ await assertNoSymlink(root, target);
1294
1379
  await writeFile(target, bytes);
1295
1380
  written.push(target);
1296
1381
  }
@@ -1314,12 +1399,25 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
1314
1399
  var SQUIRCLE = squirclePath();
1315
1400
  var C = CANVAS;
1316
1401
  function maskPath(shape) {
1317
- 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`;
1402
+ if (shape === "circle") {
1403
+ const r = C / 2 + 32;
1404
+ 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`;
1405
+ }
1318
1406
  if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
1319
1407
  return SQUIRCLE;
1320
1408
  }
1321
1409
 
1322
1410
  // ../render/src/compose.ts
1411
+ function rasterRenderOptions(ro) {
1412
+ if (!ro) return {};
1413
+ return {
1414
+ ...ro.mask ? { mask: ro.mask, rim: ro.mask !== "none" } : {},
1415
+ ...ro.tint ? { tint: ro.tint } : {},
1416
+ ...ro.idiom ? { idiom: ro.idiom } : {},
1417
+ ...ro.background === false ? { background: false } : {},
1418
+ ...ro.inset ? { inset: ro.inset } : {}
1419
+ };
1420
+ }
1323
1421
  var C2 = CANVAS;
1324
1422
  var H = C2 / 2;
1325
1423
  var b64Cache = /* @__PURE__ */ new WeakMap();
@@ -1417,7 +1515,8 @@ function renderSvg(doc, opts = {}) {
1417
1515
  let darken = 0;
1418
1516
  if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
1419
1517
  let bgPaint;
1420
- const full = { x: 0, y: 0, w: C2, h: C2 };
1518
+ const P = mask === "circle" ? 32 : 0;
1519
+ const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
1421
1520
  const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" || s.appearance === appearance);
1422
1521
  if (monoOverride) {
1423
1522
  bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
@@ -1436,12 +1535,16 @@ function renderSvg(doc, opts = {}) {
1436
1535
  if (bgFill === void 0) bgFill = "automatic";
1437
1536
  bgPaint = paintFor(bgFill, defs, full, dark);
1438
1537
  }
1538
+ if (opts.background === false) {
1539
+ bgPaint = null;
1540
+ darken = 0;
1541
+ }
1439
1542
  const groups = FRONT_FIRST ? [...doc.groups].reverse() : [...doc.groups];
1440
1543
  let stackId = defs.id("s");
1441
1544
  const bgStackId = stackId;
1442
1545
  const contentIds = [];
1443
1546
  defs.add(
1444
- `<g id="${stackId}">${bgPaint ? `<rect width="${C2}" height="${C2}" fill="${bgPaint}"/>` : ""}` + (darken ? `<rect width="${C2}" height="${C2}" fill="#000" opacity="${darken}"/>` : "") + `</g>`
1547
+ `<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>`
1445
1548
  );
1446
1549
  let plainId = bgStackId;
1447
1550
  for (const g of groups) {
@@ -1481,7 +1584,7 @@ function renderSvg(doc, opts = {}) {
1481
1584
  iid = defs.id("img");
1482
1585
  imgId.set(p.asset.name, iid);
1483
1586
  defs.add(
1484
- `<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)}"/>`
1587
+ `<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)}"/>`
1485
1588
  );
1486
1589
  }
1487
1590
  const s = p.w / p.asset.width;
@@ -1538,9 +1641,21 @@ function renderSvg(doc, opts = {}) {
1538
1641
  `<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H} ${H}) scale(${n(zoom)}) translate(${-H} ${-H})"/></g>`
1539
1642
  );
1540
1643
  }
1541
- const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (translucent ? 1 - tr.value * 0.55 : 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
1644
+ const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
1645
+ let fadeMask = "";
1646
+ if (translucent) {
1647
+ const v = Math.min(1, Math.max(0, tr.value));
1648
+ const gid = defs.id("tg");
1649
+ const mid = defs.id("tm");
1650
+ defs.add(
1651
+ `<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>`
1652
+ );
1653
+ fadeMask = ` mask="url(#${mid})"`;
1654
+ }
1542
1655
  const gBlend = cssBlend(resolveProp(g.props["blend-mode"], ctx, "normal"));
1543
- out.push(`<use href="#${contentId}"${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}/>`);
1656
+ out.push(
1657
+ `<g${fadeMask}${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}><use href="#${contentId}"/></g>`
1658
+ );
1544
1659
  const specular = resolveProp(g.props.specular, ctx, true);
1545
1660
  if (effects && specular && glassEls.length) {
1546
1661
  const k = 7;
@@ -1587,7 +1702,14 @@ function renderSvg(doc, opts = {}) {
1587
1702
  const clipId = defs.id("clip");
1588
1703
  const shape = maskPath(mask);
1589
1704
  defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
1590
- body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
1705
+ const M = Math.max(0, opts.inset ?? 0);
1706
+ if (M > 0) {
1707
+ const sid = defs.id("ish");
1708
+ defs.add(
1709
+ `<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>`
1710
+ );
1711
+ body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
1712
+ } else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
1591
1713
  if (effects && opts.rim !== false && mask !== "none") {
1592
1714
  const rid = defs.id("rim");
1593
1715
  const lx = H + toLight.x * H;
@@ -1597,7 +1719,7 @@ function renderSvg(doc, opts = {}) {
1597
1719
  );
1598
1720
  body.push(`<g clip-path="url(#${clipId})"><path d="${shape}" fill="none" stroke="url(#${rid})" stroke-width="10"/></g>`);
1599
1721
  }
1600
- 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>`;
1722
+ 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>`;
1601
1723
  }
1602
1724
  function cssBlend(mode) {
1603
1725
  if (!mode || mode === "normal") return null;
@@ -1656,7 +1778,7 @@ async function renderSheetPng(doc, opts = {}) {
1656
1778
  return svgToPng(renderSheetSvg(doc, opts), cell * 3);
1657
1779
  }
1658
1780
  function nodeRasterizer(doc, opts = {}) {
1659
- return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...ro?.mask ? { mask: ro.mask, rim: ro.mask !== "none" } : {}, ...ro?.tint ? { tint: ro.tint } : {} });
1781
+ return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...rasterRenderOptions(ro) });
1660
1782
  }
1661
1783
 
1662
1784
  // ../mcp/src/tools.ts
@@ -1675,7 +1797,7 @@ https://developer.apple.com/design/human-interface-guidelines/app-icons
1675
1797
  - 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.
1676
1798
  - Canvas: 1024\xD71024 for iOS/iPadOS/macOS (masked to a rounded rectangle) and 1088\xD71088 for watchOS (masked to a circle).
1677
1799
  - Appearances: Default, Dark, Clear light, Clear dark, Tinted light, Tinted dark. If you don't provide a variant, the system generates one.
1678
- - 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.
1800
+ - 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).
1679
1801
  - In Icon Composer the appearances are Default, Dark and Mono (Mono covers clear and tinted); icon.json stores Mono overrides under the "tinted" key.
1680
1802
 
1681
1803
  ## Layers
@@ -1735,7 +1857,7 @@ ${table("Layer", LAYER_ATTRS)}
1735
1857
  `;
1736
1858
 
1737
1859
  // ../mcp/src/tools.ts
1738
- var VERSION = "0.1.0";
1860
+ var VERSION = "0.1.1";
1739
1861
  var appearanceEnum = z2.enum(APPEARANCES);
1740
1862
  var overrideEnum = z2.enum(["dark", "tinted"]);
1741
1863
  var maskEnum = z2.enum(["squircle", "circle", "none"]);
@@ -2074,10 +2196,14 @@ function createServer(ws) {
2074
2196
  const doc = await ws.read(full);
2075
2197
  const name = basename(full).replace(/\.icon(\.zip)?$/, "");
2076
2198
  const raster = nodeRasterizer(doc);
2199
+ const missing = unrenderableLayers(doc);
2200
+ if (missing.length && formats.some((f) => f !== "icon"))
2201
+ throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them first (icon_validate).`);
2077
2202
  const written = [];
2078
2203
  if (formats.includes("icon")) {
2079
- const files = writeBundle(doc, doc.extra);
2080
- written.push(...await writeFiles(join2(outDir, `${name}.icon`), files));
2204
+ const dest = join2(outDir, `${name}.icon`);
2205
+ await saveIcon(dest, doc);
2206
+ written.push(join2(dest, "icon.json"), ...Object.keys(doc.assets).map((a) => join2(dest, "Assets", a)));
2081
2207
  }
2082
2208
  if (formats.includes("icns")) {
2083
2209
  const p = join2(outDir, `${name}.icns`);
@@ -2085,7 +2211,7 @@ function createServer(ws) {
2085
2211
  await writeFile2(p, await buildIcns(raster));
2086
2212
  written.push(p);
2087
2213
  }
2088
- if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster)));
2214
+ if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
2089
2215
  if (formats.includes("png"))
2090
2216
  written.push(
2091
2217
  ...await writeFiles(
@@ -2245,7 +2371,7 @@ Usage
2245
2371
  Render an approximate preview PNG
2246
2372
  fresnel export <Name.icon> -o <dir> [--formats icon,icns,appiconset,png]
2247
2373
  [--sizes 1024,512,...] [--appearances default,dark,...]
2248
- [--tint #RRGGBB]
2374
+ [--tint #RRGGBB] [--force]
2249
2375
  fresnel mcp [--root <dir>]... Start the MCP server on stdio
2250
2376
 
2251
2377
  Render options: --mask squircle|circle|none, --tint #RRGGBB (tinted appearances)
@@ -2327,6 +2453,7 @@ async function main() {
2327
2453
  tint: { type: "string" },
2328
2454
  mask: { type: "string" },
2329
2455
  root: { type: "string", short: "r", multiple: true },
2456
+ force: { type: "boolean" },
2330
2457
  help: { type: "boolean", short: "h" }
2331
2458
  }
2332
2459
  });
@@ -2381,14 +2508,19 @@ async function main() {
2381
2508
  const formats = (values.formats ?? "icon,icns,appiconset,png").split(",").map((s) => s.trim());
2382
2509
  const raster = nodeRasterizer(doc, values.tint ? { tint: values.tint } : {});
2383
2510
  const outDir = resolve3(values.out);
2511
+ const missing = unrenderableLayers(doc);
2512
+ if (missing.length && formats.some((f) => f !== "icon") && !values.force)
2513
+ throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them, or pass --force.`);
2384
2514
  let n2 = 0;
2385
2515
  for (const f of formats) {
2386
- if (f === "icon") n2 += (await writeFiles(join3(outDir, `${name}.icon`), writeBundle(doc, doc.extra))).length;
2387
- else if (f === "icns") {
2516
+ if (f === "icon") {
2517
+ await saveIcon(join3(outDir, `${name}.icon`), doc);
2518
+ n2 += 1 + Object.keys(doc.assets).length;
2519
+ } else if (f === "icns") {
2388
2520
  await mkdir3(outDir, { recursive: true });
2389
2521
  await writeFile3(join3(outDir, `${name}.icns`), await buildIcns(raster));
2390
2522
  n2++;
2391
- } else if (f === "appiconset") n2 += (await writeFiles(join3(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster))).length;
2523
+ } else if (f === "appiconset") n2 += (await writeFiles(join3(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) }))).length;
2392
2524
  else if (f === "png") {
2393
2525
  const sizes = (values.sizes ?? "1024,512,256,128,64,32,16").split(",").map(Number);
2394
2526
  n2 += (await writeFiles(join3(outDir, `${name}-png`), await buildPngSet(raster, appearances(values.appearances), sizes))).length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fresnel-icon",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "fresnel CLI: build, validate, render and export Liquid Glass app icons (.icon bundles) from the command line or CI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,8 +38,8 @@
38
38
  "devDependencies": {
39
39
  "tsup": "9.0.0",
40
40
  "tsx": "4.23.15",
41
- "@fresnel/core": "0.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",