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.
- package/dist/server.js +171 -45
- 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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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\
|
|
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, """)}"`);
|
|
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
|
|
569
|
-
|
|
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
|
-
|
|
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
|
|
773
|
-
} else if (!rel.split("/").some((p) => p === "__MACOSX"
|
|
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.
|
|
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: "
|
|
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.
|
|
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"] =
|
|
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
|
|
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(
|
|
1295
|
-
const r = relative(
|
|
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")
|
|
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
|
|
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) * (
|
|
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(
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
2078
|
-
|
|
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.
|
|
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.
|
|
42
|
-
"@fresnel/render": "0.1.
|
|
41
|
+
"@fresnel/core": "0.1.1",
|
|
42
|
+
"@fresnel/render": "0.1.1"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsup",
|