fresnel-icon 0.1.0 → 0.2.0
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/README.md +22 -0
- package/dist/cli.js +1265 -386
- 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
|
}
|
|
@@ -101,6 +103,9 @@ function normalizeColorInput(input) {
|
|
|
101
103
|
const n2 = (i) => parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
102
104
|
return formatColor({ space: "srgb", c: [n2(0), n2(2), n2(4), hex.length === 8 ? n2(6) : 1] });
|
|
103
105
|
}
|
|
106
|
+
function luminance({ r, g, b }) {
|
|
107
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
108
|
+
}
|
|
104
109
|
function mix(a, b, t) {
|
|
105
110
|
return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, b: a.b + (b.b - a.b) * t, a: a.a + (b.a - a.a) * t };
|
|
106
111
|
}
|
|
@@ -275,9 +280,10 @@ var BlendModeSchema = z.enum(BLEND_MODES);
|
|
|
275
280
|
var PositionSchema = z.object({ scale: z.number().positive(), "translation-in-points": z.tuple([z.number(), z.number()]) }).loose();
|
|
276
281
|
var ShadowSchema = z.object({ kind: z.enum(["none", "neutral", "layer-color"]), opacity: z.number().min(0).max(1) }).loose();
|
|
277
282
|
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(
|
|
283
|
+
var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(-1).max(1), depth: z.number().min(-1).max(1) }).loose();
|
|
279
284
|
var SupportedPlatformsSchema = z.object({
|
|
280
|
-
|
|
285
|
+
// "shared", ["iOS"] or {"platforms": ["iOS"]} (both list forms appear in community tools)
|
|
286
|
+
squares: z.union([z.literal("shared"), z.array(z.string()), z.object({ platforms: z.array(z.string()) }).loose()]).optional(),
|
|
281
287
|
circles: z.array(z.string()).optional()
|
|
282
288
|
}).loose();
|
|
283
289
|
var unit = z.number().min(0).max(1);
|
|
@@ -421,14 +427,29 @@ var DROP_ELEMENTS = /* @__PURE__ */ new Set([
|
|
|
421
427
|
"animatecolor",
|
|
422
428
|
"discard",
|
|
423
429
|
"handler",
|
|
424
|
-
"listener"
|
|
425
|
-
"a"
|
|
430
|
+
"listener"
|
|
426
431
|
]);
|
|
432
|
+
var UNWRAP_ELEMENTS = /* @__PURE__ */ new Set(["a"]);
|
|
433
|
+
function decodeForCheck(v) {
|
|
434
|
+
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();
|
|
435
|
+
}
|
|
436
|
+
function expandInternalEntities(svg) {
|
|
437
|
+
const doctype = /<!DOCTYPE[^\[>]*\[([\s\S]*?)\]\s*>/i.exec(svg);
|
|
438
|
+
if (!doctype) return svg;
|
|
439
|
+
const ents = /* @__PURE__ */ new Map();
|
|
440
|
+
for (const m of doctype[1].matchAll(/<!ENTITY\s+([\w.-]+)\s+(?:"([^"]*)"|'([^']*)')\s*>/g)) {
|
|
441
|
+
const v = m[2] ?? m[3] ?? "";
|
|
442
|
+
if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
|
|
443
|
+
}
|
|
444
|
+
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);
|
|
446
|
+
}
|
|
427
447
|
function safeUrlRef(v) {
|
|
428
448
|
const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
|
|
429
449
|
return t.startsWith("#") || /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]*$/i.test(t);
|
|
430
450
|
}
|
|
431
|
-
function cleanCss(
|
|
451
|
+
function cleanCss(css0) {
|
|
452
|
+
const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
|
|
432
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, "");
|
|
433
454
|
}
|
|
434
455
|
function cleanAttrs(raw) {
|
|
@@ -446,9 +467,13 @@ function cleanAttrs(raw) {
|
|
|
446
467
|
if (lname === "href" || lname.endsWith(":href") || lname === "src") {
|
|
447
468
|
if (!safeUrlRef(value)) continue;
|
|
448
469
|
}
|
|
449
|
-
|
|
470
|
+
const plain = decodeForCheck(value);
|
|
471
|
+
if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
|
|
450
472
|
let v = value;
|
|
451
|
-
if (lname === "style" || /url\
|
|
473
|
+
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;
|
|
476
|
+
}
|
|
452
477
|
out.push(`${name}="${v.replace(/"/g, """)}"`);
|
|
453
478
|
}
|
|
454
479
|
return out.length ? " " + out.join(" ") : "";
|
|
@@ -518,6 +543,7 @@ function sanitizeOnce(svg) {
|
|
|
518
543
|
i = j + 1;
|
|
519
544
|
const [, close, tag, attrs, selfClose] = m;
|
|
520
545
|
const local = tag.toLowerCase().replace(/^.*:/, "");
|
|
546
|
+
if (UNWRAP_ELEMENTS.has(local) && !skip.length) continue;
|
|
521
547
|
if (close) {
|
|
522
548
|
if (skip.length) {
|
|
523
549
|
if (skip[skip.length - 1] === local) skip.pop();
|
|
@@ -542,7 +568,7 @@ function sanitizeOnce(svg) {
|
|
|
542
568
|
return out;
|
|
543
569
|
}
|
|
544
570
|
function sanitizeSvg(svg) {
|
|
545
|
-
let s = svg;
|
|
571
|
+
let s = expandInternalEntities(svg);
|
|
546
572
|
for (let n2 = 0; n2 < 5; n2++) {
|
|
547
573
|
const next = sanitizeOnce(s);
|
|
548
574
|
if (next === s) break;
|
|
@@ -561,8 +587,9 @@ function svgFacts(svg) {
|
|
|
561
587
|
function makeAsset(name, bytes) {
|
|
562
588
|
const kind = sniffKind(name, bytes);
|
|
563
589
|
if (kind === "svg") {
|
|
564
|
-
const
|
|
565
|
-
|
|
590
|
+
const original = td.decode(bytes);
|
|
591
|
+
const text2 = sanitizeSvg(original);
|
|
592
|
+
return { name, kind, bytes, ...text2 !== original ? { display: te.encode(text2) } : {}, ...svgSize(text2) };
|
|
566
593
|
}
|
|
567
594
|
if (kind === "png") return { name, kind, bytes, ...pngSize(bytes) };
|
|
568
595
|
if (kind === "jpg") return { name, kind, bytes, ...jpgSize(bytes) };
|
|
@@ -716,7 +743,28 @@ function encodePngRgb(width, height, rgba, bg = [0, 0, 0]) {
|
|
|
716
743
|
}
|
|
717
744
|
function flattenPng(png, bg = [0, 0, 0]) {
|
|
718
745
|
const bmp = decodePng(png);
|
|
719
|
-
|
|
746
|
+
if (!bmp) throw new Error("Could not flatten the PNG (unsupported encoding)");
|
|
747
|
+
return tagSrgb(encodePngRgb(bmp.width, bmp.height, bmp.data, bg));
|
|
748
|
+
}
|
|
749
|
+
function tagSrgb(png) {
|
|
750
|
+
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
751
|
+
if (png.length < 33 || sig.some((b, i) => png[i] !== b)) return png;
|
|
752
|
+
let o = 8;
|
|
753
|
+
const dv = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
754
|
+
while (o + 8 <= png.length) {
|
|
755
|
+
const len = dv.getUint32(o);
|
|
756
|
+
const type = String.fromCharCode(png[o + 4], png[o + 5], png[o + 6], png[o + 7]);
|
|
757
|
+
if (type === "sRGB" || type === "iCCP") return png;
|
|
758
|
+
if (type === "IDAT") break;
|
|
759
|
+
o += 12 + len;
|
|
760
|
+
}
|
|
761
|
+
const ihdrEnd = 8 + 12 + 13;
|
|
762
|
+
const srgb = chunk("sRGB", new Uint8Array([0]));
|
|
763
|
+
const out = new Uint8Array(png.length + srgb.length);
|
|
764
|
+
out.set(png.subarray(0, ihdrEnd), 0);
|
|
765
|
+
out.set(srgb, ihdrEnd);
|
|
766
|
+
out.set(png.subarray(ihdrEnd), ihdrEnd + srgb.length);
|
|
767
|
+
return out;
|
|
720
768
|
}
|
|
721
769
|
|
|
722
770
|
// ../core/src/bundle.ts
|
|
@@ -753,6 +801,10 @@ function serializeIconJson(doc) {
|
|
|
753
801
|
const json = writeProps(doc.props, doc.order, { groups });
|
|
754
802
|
return JSON.stringify(json, null, 2).replace(/^(\s*"[^"]*"):/gm, "$1 :") + "\n";
|
|
755
803
|
}
|
|
804
|
+
function isSafeBundlePath(rel) {
|
|
805
|
+
if (!rel || rel.length > 512 || rel.startsWith("/") || /[\\\u0000-\u001f:]/.test(rel)) return false;
|
|
806
|
+
return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
|
|
807
|
+
}
|
|
756
808
|
function readBundle(files) {
|
|
757
809
|
const jsonKey = Object.keys(files).find((k) => k === "icon.json" || k.endsWith("/icon.json"));
|
|
758
810
|
if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
|
|
@@ -763,10 +815,11 @@ function readBundle(files) {
|
|
|
763
815
|
for (const [k, v] of Object.entries(files)) {
|
|
764
816
|
if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
|
|
765
817
|
const rel = k.slice(prefix.length);
|
|
818
|
+
if (!isSafeBundlePath(rel)) continue;
|
|
766
819
|
if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
|
|
767
820
|
const name = rel.slice("Assets/".length);
|
|
768
|
-
if (name
|
|
769
|
-
} else if (!rel.split("/").some((p) => p === "__MACOSX"
|
|
821
|
+
if (name) assets[name] = makeAsset(name, v);
|
|
822
|
+
} else if (!rel.split("/").some((p) => p === "__MACOSX")) {
|
|
770
823
|
extra[rel] = v;
|
|
771
824
|
}
|
|
772
825
|
}
|
|
@@ -892,12 +945,7 @@ function uniqueName(existing, wanted0) {
|
|
|
892
945
|
while (existing.includes(`${wanted} ${i}`)) i++;
|
|
893
946
|
return `${wanted} ${i}`;
|
|
894
947
|
}
|
|
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
948
|
function addGroup(doc, opts = {}) {
|
|
900
|
-
assertGroupRoom(doc);
|
|
901
949
|
const name = uniqueName(doc.groups.map(nameOf), opts.name ?? "Group");
|
|
902
950
|
const g = { id: newId("g"), props: {}, order: [], layers: [] };
|
|
903
951
|
setPropValue(g.props, "name", name);
|
|
@@ -935,7 +983,8 @@ function addLayer(doc, groupId, asset, opts = {}) {
|
|
|
935
983
|
const l = { id: newId("l"), props: {}, order: [] };
|
|
936
984
|
setPropValue(l.props, "image-name", imageName);
|
|
937
985
|
setPropValue(l.props, "name", uniqueName(g.layers.map(nameOf), base));
|
|
938
|
-
l.
|
|
986
|
+
setPropValue(l.props, "glass", true);
|
|
987
|
+
l.order = ["glass", "image-name", "name"];
|
|
939
988
|
for (const [k, v] of Object.entries(opts.props ?? {})) setProp(doc, { kind: "layer", id: l.id }, k, v, void 0, l);
|
|
940
989
|
g.layers.splice(clampIndex(opts.index ?? 0, g.layers.length), 0, l);
|
|
941
990
|
return l;
|
|
@@ -1022,9 +1071,20 @@ var RULES = {
|
|
|
1022
1071
|
"V-06": { severity: "info", title: "Very thin strokes may disappear at small sizes" },
|
|
1023
1072
|
"V-07": { severity: "info", title: "SVG contains text or custom effects" },
|
|
1024
1073
|
"V-08": { severity: "warning", title: "Appearance is not customized" },
|
|
1025
|
-
"V-09": { severity: "
|
|
1074
|
+
"V-09": { severity: "warning", title: "More than 4 groups" },
|
|
1026
1075
|
"V-10": { severity: "error", title: "Unsupported image format" }
|
|
1027
1076
|
};
|
|
1077
|
+
function fillHasAlpha(fill) {
|
|
1078
|
+
if (!fill || typeof fill !== "object") return false;
|
|
1079
|
+
const f = fill;
|
|
1080
|
+
const colors = [f.solid, f["automatic-gradient"], ...Array.isArray(f["linear-gradient"]) ? f["linear-gradient"] : []];
|
|
1081
|
+
return colors.some((c) => {
|
|
1082
|
+
const p = typeof c === "string" ? parseColor(c) : null;
|
|
1083
|
+
if (!p) return false;
|
|
1084
|
+
const a = p.space === "gray" || p.space === "extended-gray" ? p.c[1] : p.c[3];
|
|
1085
|
+
return a !== void 0 && a < 1;
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1028
1088
|
function validate(doc) {
|
|
1029
1089
|
const out = [];
|
|
1030
1090
|
const push = (id, message, target, ref) => out.push({ id, severity: RULES[id].severity, message, target, ...ref ? { ref } : {} });
|
|
@@ -1042,8 +1102,9 @@ function validate(doc) {
|
|
|
1042
1102
|
checkProps("document", doc.props, "document", { kind: "document" });
|
|
1043
1103
|
const fill = resolveProp(doc.props.fill, { appearance: "default" }, "automatic");
|
|
1044
1104
|
if (fill === "none") push("V-05", 'The background fill is "none". Icons need an opaque, full-bleed background.', "document", { kind: "document" });
|
|
1105
|
+
else if (fillHasAlpha(fill)) push("V-05", "The background color is partly transparent. App Store icons are flattened onto black.", "document", { kind: "document" });
|
|
1045
1106
|
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.
|
|
1107
|
+
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
1108
|
let anyDark = false;
|
|
1048
1109
|
let anyTinted = false;
|
|
1049
1110
|
const scan = (props) => {
|
|
@@ -1147,7 +1208,27 @@ function summarizeIssues(issues) {
|
|
|
1147
1208
|
|
|
1148
1209
|
// ../core/src/export.ts
|
|
1149
1210
|
import { strToU8 as strToU82 } from "fflate";
|
|
1211
|
+
function targetsWatch(doc) {
|
|
1212
|
+
const sp = baseValue(doc.props["supported-platforms"]);
|
|
1213
|
+
return Array.isArray(sp?.circles) && sp.circles.includes("watchOS");
|
|
1214
|
+
}
|
|
1215
|
+
function unrenderableLayers(doc) {
|
|
1216
|
+
const out = [];
|
|
1217
|
+
for (const g of doc.groups) {
|
|
1218
|
+
if (baseValue(g.props.hidden) === true) continue;
|
|
1219
|
+
for (const l of g.layers) {
|
|
1220
|
+
if (baseValue(l.props.hidden) === true) continue;
|
|
1221
|
+
const name = baseValue(l.props["image-name"]);
|
|
1222
|
+
const a = typeof name === "string" ? doc.assets[name] : void 0;
|
|
1223
|
+
if (!a || a.kind === "pdf" || a.kind === "other") out.push(`${baseValue(g.props.name) ?? "Group"}/${baseValue(l.props.name) ?? name ?? "Layer"}`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return out;
|
|
1227
|
+
}
|
|
1228
|
+
var MAC_INSET = 100;
|
|
1150
1229
|
var ICNS_TYPES = [
|
|
1230
|
+
["icp4", 16],
|
|
1231
|
+
["icp5", 32],
|
|
1151
1232
|
["ic11", 32],
|
|
1152
1233
|
// 16@2x
|
|
1153
1234
|
["ic12", 64],
|
|
@@ -1186,15 +1267,15 @@ function encodeIcns(pngBySize) {
|
|
|
1186
1267
|
}
|
|
1187
1268
|
async function buildIcns(rasterize, appearance = "default") {
|
|
1188
1269
|
const pngs = {};
|
|
1189
|
-
for (const s of ICNS_SIZES) pngs[s] = await rasterize(appearance, s);
|
|
1270
|
+
for (const s of ICNS_SIZES) pngs[s] = tagSrgb(await rasterize(appearance, s, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
|
|
1190
1271
|
return encodeIcns(pngs);
|
|
1191
1272
|
}
|
|
1192
|
-
async function buildAppIconSet(rasterize) {
|
|
1273
|
+
async function buildAppIconSet(rasterize, opts = {}) {
|
|
1193
1274
|
const files = {};
|
|
1194
1275
|
const images = [];
|
|
1195
|
-
files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none" }));
|
|
1196
|
-
files["AppIcon-Dark.png"] =
|
|
1197
|
-
files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", tint: "#FFFFFF" }));
|
|
1276
|
+
files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "iOS" }));
|
|
1277
|
+
files["AppIcon-Dark.png"] = tagSrgb(await rasterize("dark", 1024, { mask: "none", idiom: "iOS", background: false }));
|
|
1278
|
+
files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", idiom: "iOS", tint: "#FFFFFF" }));
|
|
1198
1279
|
images.push({ filename: "AppIcon.png", idiom: "universal", platform: "ios", size: "1024x1024" });
|
|
1199
1280
|
images.push({
|
|
1200
1281
|
appearances: [{ appearance: "luminosity", value: "dark" }],
|
|
@@ -1214,19 +1295,124 @@ async function buildAppIconSet(rasterize) {
|
|
|
1214
1295
|
for (const scale of [1, 2]) {
|
|
1215
1296
|
const px = pt * scale;
|
|
1216
1297
|
const name = `mac-${pt}@${scale}x.png`;
|
|
1217
|
-
files[name] = await rasterize("default", px, { mask: "squircle" });
|
|
1298
|
+
files[name] = tagSrgb(await rasterize("default", px, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
|
|
1218
1299
|
images.push({ filename: name, idiom: "mac", scale: `${scale}x`, size: `${pt}x${pt}` });
|
|
1219
1300
|
}
|
|
1220
1301
|
}
|
|
1302
|
+
if (opts.watch) {
|
|
1303
|
+
files["AppIcon-Watch.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "watchOS" }));
|
|
1304
|
+
images.push({ filename: "AppIcon-Watch.png", idiom: "universal", platform: "watchos", size: "1024x1024" });
|
|
1305
|
+
}
|
|
1221
1306
|
files["Contents.json"] = strToU82(JSON.stringify({ images, info: { author: "fresnel", version: 1 } }, null, 2) + "\n");
|
|
1222
1307
|
return files;
|
|
1223
1308
|
}
|
|
1224
1309
|
async function buildPngSet(rasterize, appearances, sizes) {
|
|
1225
1310
|
const files = {};
|
|
1226
|
-
for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = await rasterize(a, s, { mask: "squircle" });
|
|
1311
|
+
for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = tagSrgb(await rasterize(a, s, { mask: "squircle" }));
|
|
1227
1312
|
return files;
|
|
1228
1313
|
}
|
|
1229
1314
|
|
|
1315
|
+
// ../core/src/arrange.ts
|
|
1316
|
+
var H = CANVAS / 2;
|
|
1317
|
+
function layoutBoxes(doc, ctx, opts = {}) {
|
|
1318
|
+
const layers = [];
|
|
1319
|
+
const groups = [];
|
|
1320
|
+
for (const g of doc.groups) {
|
|
1321
|
+
if (!opts.includeHidden && resolveProp(g.props.hidden, ctx, false)) continue;
|
|
1322
|
+
const gp = resolveProp(g.props.position, ctx, null);
|
|
1323
|
+
const gs = gp?.scale ?? 1;
|
|
1324
|
+
const [gx, gy] = gp?.["translation-in-points"] ?? [0, 0];
|
|
1325
|
+
const toCanvas = (x, y) => ({ x: H + gx + (x - H) * gs, y: H + gy + (y - H) * gs });
|
|
1326
|
+
const inGroup = [];
|
|
1327
|
+
for (const l of g.layers) {
|
|
1328
|
+
if (!opts.includeHidden && resolveProp(l.props.hidden, ctx, false)) continue;
|
|
1329
|
+
const img = resolveProp(l.props["image-name"], ctx, "");
|
|
1330
|
+
const a = doc.assets[img];
|
|
1331
|
+
if (!a) continue;
|
|
1332
|
+
const p = resolveProp(l.props.position, ctx, null);
|
|
1333
|
+
const s = p?.scale ?? 1;
|
|
1334
|
+
const [tx, ty] = p?.["translation-in-points"] ?? [0, 0];
|
|
1335
|
+
const w = a.width * s;
|
|
1336
|
+
const h = a.height * s;
|
|
1337
|
+
const tl = toCanvas(H + tx - w / 2, H + ty - h / 2);
|
|
1338
|
+
const b = { kind: "layer", id: l.id, groupId: g.id, x: tl.x, y: tl.y, w: w * gs, h: h * gs };
|
|
1339
|
+
inGroup.push(b);
|
|
1340
|
+
layers.push(b);
|
|
1341
|
+
}
|
|
1342
|
+
if (inGroup.length) groups.push({ kind: "group", id: g.id, groupId: g.id, ...unionBox(inGroup) });
|
|
1343
|
+
}
|
|
1344
|
+
return { layers, groups };
|
|
1345
|
+
}
|
|
1346
|
+
function unionBox(boxes) {
|
|
1347
|
+
if (!boxes.length) return null;
|
|
1348
|
+
const x = Math.min(...boxes.map((b) => b.x));
|
|
1349
|
+
const y = Math.min(...boxes.map((b) => b.y));
|
|
1350
|
+
const x2 = Math.max(...boxes.map((b) => b.x + b.w));
|
|
1351
|
+
const y2 = Math.max(...boxes.map((b) => b.y + b.h));
|
|
1352
|
+
return { x, y, w: x2 - x, h: y2 - y };
|
|
1353
|
+
}
|
|
1354
|
+
function boxOf(doc, ref, ctx) {
|
|
1355
|
+
if (ref.kind === "document") return void 0;
|
|
1356
|
+
const geo = layoutBoxes(doc, ctx, { includeHidden: true });
|
|
1357
|
+
return (ref.kind === "group" ? geo.groups : geo.layers).find((b) => b.id === ref.id);
|
|
1358
|
+
}
|
|
1359
|
+
function moveBy(doc, ref, dx, dy, ctx, variant) {
|
|
1360
|
+
if (ref.kind === "document" || Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) return;
|
|
1361
|
+
let props;
|
|
1362
|
+
let scale = 1;
|
|
1363
|
+
if (ref.kind === "group") {
|
|
1364
|
+
const g = findGroup(doc, ref.id);
|
|
1365
|
+
if (!g) throw new OpError("Group not found");
|
|
1366
|
+
props = g.props;
|
|
1367
|
+
} else {
|
|
1368
|
+
const f = findLayer(doc, ref.id);
|
|
1369
|
+
if (!f) throw new OpError("Layer not found");
|
|
1370
|
+
props = f.layer.props;
|
|
1371
|
+
scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
|
|
1372
|
+
}
|
|
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
|
+
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);
|
|
1377
|
+
}
|
|
1378
|
+
function alignNodes(doc, refs, how, ctx, opts = {}) {
|
|
1379
|
+
const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
|
|
1380
|
+
if (!boxes.length) return 0;
|
|
1381
|
+
const ref = opts.basis === "canvas" || boxes.length === 1 ? { x: 0, y: 0, w: CANVAS, h: CANVAS } : unionBox(boxes.map((b) => b.box));
|
|
1382
|
+
let moved = 0;
|
|
1383
|
+
for (const { ref: r, box: b } of boxes) {
|
|
1384
|
+
let dx = 0;
|
|
1385
|
+
let dy = 0;
|
|
1386
|
+
if (how === "left") dx = ref.x - b.x;
|
|
1387
|
+
else if (how === "right") dx = ref.x + ref.w - (b.x + b.w);
|
|
1388
|
+
else if (how === "hcenter") dx = ref.x + ref.w / 2 - (b.x + b.w / 2);
|
|
1389
|
+
else if (how === "top") dy = ref.y - b.y;
|
|
1390
|
+
else if (how === "bottom") dy = ref.y + ref.h - (b.y + b.h);
|
|
1391
|
+
else dy = ref.y + ref.h / 2 - (b.y + b.h / 2);
|
|
1392
|
+
if (Math.abs(dx) < 1e-6 && Math.abs(dy) < 1e-6) continue;
|
|
1393
|
+
moveBy(doc, r, dx, dy, ctx, opts.variant);
|
|
1394
|
+
moved++;
|
|
1395
|
+
}
|
|
1396
|
+
return moved;
|
|
1397
|
+
}
|
|
1398
|
+
function distributeNodes(doc, refs, axis, ctx, opts = {}) {
|
|
1399
|
+
const items = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
|
|
1400
|
+
if (items.length < 3) return 0;
|
|
1401
|
+
const c = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
|
|
1402
|
+
items.sort((a, b) => c(a.box) - c(b.box));
|
|
1403
|
+
const first = c(items[0].box);
|
|
1404
|
+
const step = (c(items[items.length - 1].box) - first) / (items.length - 1);
|
|
1405
|
+
let moved = 0;
|
|
1406
|
+
items.forEach((it, i) => {
|
|
1407
|
+
if (i === 0 || i === items.length - 1) return;
|
|
1408
|
+
const d = first + step * i - c(it.box);
|
|
1409
|
+
if (Math.abs(d) < 1e-6) return;
|
|
1410
|
+
moveBy(doc, it.ref, axis === "horizontal" ? d : 0, axis === "vertical" ? d : 0, ctx, opts.variant);
|
|
1411
|
+
moved++;
|
|
1412
|
+
});
|
|
1413
|
+
return moved;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1230
1416
|
// ../core/src/node.ts
|
|
1231
1417
|
import { lstat, mkdir, readdir, readFile, rm, stat, writeFile, rename } from "fs/promises";
|
|
1232
1418
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
@@ -1281,22 +1467,75 @@ async function saveIcon(path, doc) {
|
|
|
1281
1467
|
const keep = new Set(Object.keys(doc.assets));
|
|
1282
1468
|
await assertNoSymlink(root, assetsDir);
|
|
1283
1469
|
for (const e of await readdir(assetsDir, { withFileTypes: true })) {
|
|
1284
|
-
if (e.isFile() && !keep.has(e.name) && e.name
|
|
1470
|
+
if (e.isFile() && !keep.has(e.name) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
|
|
1285
1471
|
}
|
|
1286
1472
|
}
|
|
1287
1473
|
async function writeFiles(dir, files) {
|
|
1288
1474
|
const written = [];
|
|
1475
|
+
const root = resolve(dir);
|
|
1289
1476
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
1290
|
-
const target = resolve(
|
|
1291
|
-
const r = relative(
|
|
1477
|
+
const target = resolve(root, ...rel.split("/"));
|
|
1478
|
+
const r = relative(root, target);
|
|
1292
1479
|
if (r.startsWith("..") || isAbsolute(r)) throw new Error(`Refusing to write outside ${dir}: ${rel}`);
|
|
1480
|
+
await assertNoSymlink(root, target);
|
|
1293
1481
|
await mkdir(dirname(target), { recursive: true });
|
|
1482
|
+
await assertNoSymlink(root, target);
|
|
1294
1483
|
await writeFile(target, bytes);
|
|
1295
1484
|
written.push(target);
|
|
1296
1485
|
}
|
|
1297
1486
|
return written;
|
|
1298
1487
|
}
|
|
1299
1488
|
|
|
1489
|
+
// ../render/src/content.ts
|
|
1490
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
1491
|
+
function alphaBounds(width, height, rgba, threshold = 8) {
|
|
1492
|
+
let x0 = width;
|
|
1493
|
+
let y0 = height;
|
|
1494
|
+
let x1 = -1;
|
|
1495
|
+
let y1 = -1;
|
|
1496
|
+
for (let y = 0; y < height; y++) {
|
|
1497
|
+
const row = y * width * 4;
|
|
1498
|
+
for (let x = 0; x < width; x++) {
|
|
1499
|
+
if (rgba[row + x * 4 + 3] > threshold) {
|
|
1500
|
+
if (x < x0) x0 = x;
|
|
1501
|
+
if (x > x1) x1 = x;
|
|
1502
|
+
if (y < y0) y0 = y;
|
|
1503
|
+
if (y > y1) y1 = y;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (x1 < 0) return null;
|
|
1508
|
+
return { x: x0 / width, y: y0 / height, w: (x1 - x0 + 1) / width, h: (y1 - y0 + 1) / height };
|
|
1509
|
+
}
|
|
1510
|
+
function setContentBox(asset, box) {
|
|
1511
|
+
cache.set(asset.bytes, box);
|
|
1512
|
+
}
|
|
1513
|
+
function contentBoxOf(asset) {
|
|
1514
|
+
if (cache.has(asset.bytes)) return cache.get(asset.bytes) ?? void 0;
|
|
1515
|
+
if (asset.kind === "png") {
|
|
1516
|
+
const bmp = decodePng(asset.bytes);
|
|
1517
|
+
const box = bmp ? alphaBounds(bmp.width, bmp.height, bmp.data) : null;
|
|
1518
|
+
cache.set(asset.bytes, box);
|
|
1519
|
+
return box ?? void 0;
|
|
1520
|
+
}
|
|
1521
|
+
if (asset.kind === "jpg") return { x: 0, y: 0, w: 1, h: 1 };
|
|
1522
|
+
return void 0;
|
|
1523
|
+
}
|
|
1524
|
+
function svgAssetsNeedingBounds(doc) {
|
|
1525
|
+
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);
|
|
1527
|
+
for (const g of doc.groups)
|
|
1528
|
+
for (const l of g.layers) {
|
|
1529
|
+
if (!hasGradient(l.props.fill)) continue;
|
|
1530
|
+
const names = [l.props["image-name"]?.plain?.value, ...(l.props["image-name"]?.specs ?? []).map((s) => s.value)];
|
|
1531
|
+
for (const n2 of names) {
|
|
1532
|
+
const a = typeof n2 === "string" ? doc.assets[n2] : void 0;
|
|
1533
|
+
if (a && a.kind === "svg" && !cache.has(a.bytes)) out.add(a);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
return [...out];
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1300
1539
|
// ../render/src/mask.ts
|
|
1301
1540
|
function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
|
|
1302
1541
|
const r = size / 2;
|
|
@@ -1314,14 +1553,78 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
|
|
|
1314
1553
|
var SQUIRCLE = squirclePath();
|
|
1315
1554
|
var C = CANVAS;
|
|
1316
1555
|
function maskPath(shape) {
|
|
1317
|
-
if (shape === "circle")
|
|
1556
|
+
if (shape === "circle") {
|
|
1557
|
+
const r = C / 2 + 32;
|
|
1558
|
+
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
|
+
}
|
|
1318
1560
|
if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
|
|
1319
1561
|
return SQUIRCLE;
|
|
1320
1562
|
}
|
|
1321
1563
|
|
|
1564
|
+
// ../render/src/luma.ts
|
|
1565
|
+
var lumaOf = (c) => {
|
|
1566
|
+
const p = parseColor(c);
|
|
1567
|
+
return p ? luminance(toRGBA(p)) : null;
|
|
1568
|
+
};
|
|
1569
|
+
function fillLuma(fill, dark) {
|
|
1570
|
+
if (fill === "system-dark" || fill === "automatic" && dark) return 0.03;
|
|
1571
|
+
if (fill === "system-light" || fill === "automatic") return 0.9;
|
|
1572
|
+
if (!fill || typeof fill !== "object") return null;
|
|
1573
|
+
const f = fill;
|
|
1574
|
+
const colors = [f.solid, f["automatic-gradient"], ...Array.isArray(f["linear-gradient"]) ? f["linear-gradient"] : []].filter((x) => typeof x === "string");
|
|
1575
|
+
const ls = colors.map(lumaOf).filter((x) => x !== null);
|
|
1576
|
+
return ls.length ? ls.reduce((a, b) => a + b, 0) / ls.length : null;
|
|
1577
|
+
}
|
|
1578
|
+
var cache2 = /* @__PURE__ */ new WeakMap();
|
|
1579
|
+
function hexLuma(hex) {
|
|
1580
|
+
let h = hex.replace("#", "");
|
|
1581
|
+
if (h.length === 3) h = h.split("").map((x) => x + x).join("");
|
|
1582
|
+
if (h.length !== 6 && h.length !== 8) return null;
|
|
1583
|
+
const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
1584
|
+
return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
|
|
1585
|
+
}
|
|
1586
|
+
function assetLuma(a) {
|
|
1587
|
+
const hit = cache2.get(a.bytes);
|
|
1588
|
+
if (hit !== void 0) return hit;
|
|
1589
|
+
let l = 1;
|
|
1590
|
+
if (a.kind === "png") {
|
|
1591
|
+
const bmp = decodePng(a.bytes);
|
|
1592
|
+
if (bmp) {
|
|
1593
|
+
let sum = 0;
|
|
1594
|
+
let wsum = 0;
|
|
1595
|
+
const step = Math.max(1, Math.floor(bmp.width * bmp.height / 4e4));
|
|
1596
|
+
for (let i = 0; i < bmp.width * bmp.height; i += step) {
|
|
1597
|
+
const al = bmp.data[i * 4 + 3] / 255;
|
|
1598
|
+
if (al < 0.05) continue;
|
|
1599
|
+
sum += luminance({ r: bmp.data[i * 4] / 255, g: bmp.data[i * 4 + 1] / 255, b: bmp.data[i * 4 + 2] / 255, a: 1 }) * al;
|
|
1600
|
+
wsum += al;
|
|
1601
|
+
}
|
|
1602
|
+
if (wsum > 0) l = sum / wsum;
|
|
1603
|
+
}
|
|
1604
|
+
} else if (a.kind === "svg") {
|
|
1605
|
+
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;
|
|
1610
|
+
}
|
|
1611
|
+
cache2.set(a.bytes, l);
|
|
1612
|
+
return l;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1322
1615
|
// ../render/src/compose.ts
|
|
1616
|
+
function rasterRenderOptions(ro) {
|
|
1617
|
+
if (!ro) return {};
|
|
1618
|
+
return {
|
|
1619
|
+
...ro.mask ? { mask: ro.mask, rim: ro.mask !== "none" } : {},
|
|
1620
|
+
...ro.tint ? { tint: ro.tint } : {},
|
|
1621
|
+
...ro.idiom ? { idiom: ro.idiom } : {},
|
|
1622
|
+
...ro.background === false ? { background: false } : {},
|
|
1623
|
+
...ro.inset ? { inset: ro.inset } : {}
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1323
1626
|
var C2 = CANVAS;
|
|
1324
|
-
var
|
|
1627
|
+
var H2 = C2 / 2;
|
|
1325
1628
|
var b64Cache = /* @__PURE__ */ new WeakMap();
|
|
1326
1629
|
var b64 = (bytes) => {
|
|
1327
1630
|
const hit = b64Cache.get(bytes);
|
|
@@ -1407,7 +1710,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1407
1710
|
const isClear = appearance === "clear-light" || appearance === "clear-dark";
|
|
1408
1711
|
const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
|
|
1409
1712
|
const tint = hexToRGBA(opts.tint ?? "#3FA7A0");
|
|
1410
|
-
const angle = (opts.lightAngle ??
|
|
1713
|
+
const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
|
|
1411
1714
|
const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
|
|
1412
1715
|
const defs = new Defs(opts.idPrefix ?? "");
|
|
1413
1716
|
const body = [];
|
|
@@ -1417,7 +1720,8 @@ function renderSvg(doc, opts = {}) {
|
|
|
1417
1720
|
let darken = 0;
|
|
1418
1721
|
if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
|
|
1419
1722
|
let bgPaint;
|
|
1420
|
-
const
|
|
1723
|
+
const P = mask === "circle" ? 32 : 0;
|
|
1724
|
+
const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
|
|
1421
1725
|
const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" || s.appearance === appearance);
|
|
1422
1726
|
if (monoOverride) {
|
|
1423
1727
|
bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
|
|
@@ -1436,12 +1740,17 @@ function renderSvg(doc, opts = {}) {
|
|
|
1436
1740
|
if (bgFill === void 0) bgFill = "automatic";
|
|
1437
1741
|
bgPaint = paintFor(bgFill, defs, full, dark);
|
|
1438
1742
|
}
|
|
1743
|
+
if (opts.background === false) {
|
|
1744
|
+
bgPaint = null;
|
|
1745
|
+
darken = 0;
|
|
1746
|
+
}
|
|
1747
|
+
const backdropLuma = (fillLuma(isClear || isTinted ? dark ? "system-dark" : "system-light" : bgFill, dark) ?? 0.9) * (1 - darken);
|
|
1439
1748
|
const groups = FRONT_FIRST ? [...doc.groups].reverse() : [...doc.groups];
|
|
1440
1749
|
let stackId = defs.id("s");
|
|
1441
1750
|
const bgStackId = stackId;
|
|
1442
1751
|
const contentIds = [];
|
|
1443
1752
|
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>`
|
|
1753
|
+
`<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
1754
|
);
|
|
1446
1755
|
let plainId = bgStackId;
|
|
1447
1756
|
for (const g of groups) {
|
|
@@ -1469,19 +1778,20 @@ function renderSvg(doc, opts = {}) {
|
|
|
1469
1778
|
const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
|
|
1470
1779
|
const w = asset.width * s;
|
|
1471
1780
|
const h = asset.height * s;
|
|
1472
|
-
placed.push({ layer: l, asset, x:
|
|
1781
|
+
placed.push({ layer: l, asset, x: H2 + tx - w / 2, y: H2 + ty - h / 2, w, h });
|
|
1473
1782
|
}
|
|
1474
1783
|
if (!placed.length) return null;
|
|
1475
1784
|
const imgId = /* @__PURE__ */ new Map();
|
|
1476
1785
|
const layerEls = [];
|
|
1477
1786
|
const glassEls = [];
|
|
1787
|
+
const glassLumas = [];
|
|
1478
1788
|
for (const p of placed) {
|
|
1479
1789
|
let iid = imgId.get(p.asset.name);
|
|
1480
1790
|
if (!iid) {
|
|
1481
1791
|
iid = defs.id("img");
|
|
1482
1792
|
imgId.set(p.asset.name, iid);
|
|
1483
1793
|
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)}"/>`
|
|
1794
|
+
`<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
1795
|
);
|
|
1486
1796
|
}
|
|
1487
1797
|
const s = p.w / p.asset.width;
|
|
@@ -1489,7 +1799,9 @@ function renderSvg(doc, opts = {}) {
|
|
|
1489
1799
|
const fill = resolveProp(p.layer.props.fill, ctx, null);
|
|
1490
1800
|
const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
|
|
1491
1801
|
const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
|
|
1492
|
-
const
|
|
1802
|
+
const cb = contentBoxOf(p.asset);
|
|
1803
|
+
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 };
|
|
1804
|
+
const paint = fill && fill !== "automatic" ? paintFor(fill, defs, fbox, dark) : null;
|
|
1493
1805
|
let el;
|
|
1494
1806
|
if (paint) {
|
|
1495
1807
|
const mid = defs.id("lm");
|
|
@@ -1500,9 +1812,12 @@ function renderSvg(doc, opts = {}) {
|
|
|
1500
1812
|
}
|
|
1501
1813
|
const wrapped = `<g${opacity < 1 ? ` opacity="${n(opacity)}"` : ""}${blend ? ` style="mix-blend-mode:${blend}"` : ""}>${el}</g>`;
|
|
1502
1814
|
layerEls.push(wrapped);
|
|
1503
|
-
if (resolveProp(p.layer.props.glass, ctx, false))
|
|
1815
|
+
if (resolveProp(p.layer.props.glass, ctx, false)) {
|
|
1816
|
+
glassEls.push(`<use href="#${iid}" ${place}/>`);
|
|
1817
|
+
glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
|
|
1818
|
+
}
|
|
1504
1819
|
}
|
|
1505
|
-
const gTransform = gpos ? ` transform="translate(${n(
|
|
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})"` : "";
|
|
1506
1821
|
const contentId = defs.id("gc");
|
|
1507
1822
|
defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
|
|
1508
1823
|
contentIds.push(contentId);
|
|
@@ -1535,13 +1850,27 @@ function renderSvg(doc, opts = {}) {
|
|
|
1535
1850
|
defs.add(`<filter id="${fid}" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
|
|
1536
1851
|
const zoom = 1.02 + (refracts ? (refr.strength ?? 0) * 0.12 : 0.015);
|
|
1537
1852
|
out.push(
|
|
1538
|
-
`<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${
|
|
1853
|
+
`<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
|
|
1857
|
+
let fadeMask = "";
|
|
1858
|
+
if (translucent) {
|
|
1859
|
+
const v = Math.min(1, Math.max(0, tr.value));
|
|
1860
|
+
const gid = defs.id("tg");
|
|
1861
|
+
const mid = defs.id("tm");
|
|
1862
|
+
defs.add(
|
|
1863
|
+
`<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>`
|
|
1539
1864
|
);
|
|
1865
|
+
fadeMask = ` mask="url(#${mid})"`;
|
|
1540
1866
|
}
|
|
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);
|
|
1542
1867
|
const gBlend = cssBlend(resolveProp(g.props["blend-mode"], ctx, "normal"));
|
|
1543
|
-
out.push(
|
|
1544
|
-
|
|
1868
|
+
out.push(
|
|
1869
|
+
`<g${fadeMask}${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}><use href="#${contentId}"/></g>`
|
|
1870
|
+
);
|
|
1871
|
+
const specularSetting = resolveProp(g.props.specular, ctx, true);
|
|
1872
|
+
const fg = glassLumas.length ? glassLumas.reduce((a, b) => a + b, 0) / glassLumas.length : 1;
|
|
1873
|
+
const specular = specularSetting === true ? fg < backdropLuma ? "inside" : "outside" : specularSetting;
|
|
1545
1874
|
if (effects && specular && glassEls.length) {
|
|
1546
1875
|
const k = 7;
|
|
1547
1876
|
const dx = n(-toLight.x * k);
|
|
@@ -1587,17 +1916,24 @@ function renderSvg(doc, opts = {}) {
|
|
|
1587
1916
|
const clipId = defs.id("clip");
|
|
1588
1917
|
const shape = maskPath(mask);
|
|
1589
1918
|
defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
|
|
1590
|
-
|
|
1919
|
+
const M = Math.max(0, opts.inset ?? 0);
|
|
1920
|
+
if (M > 0) {
|
|
1921
|
+
const sid = defs.id("ish");
|
|
1922
|
+
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>`
|
|
1924
|
+
);
|
|
1925
|
+
body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
|
|
1926
|
+
} else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
|
|
1591
1927
|
if (effects && opts.rim !== false && mask !== "none") {
|
|
1592
1928
|
const rid = defs.id("rim");
|
|
1593
|
-
const lx =
|
|
1594
|
-
const ly =
|
|
1929
|
+
const lx = H2 + toLight.x * H2;
|
|
1930
|
+
const ly = H2 + toLight.y * H2;
|
|
1595
1931
|
defs.add(
|
|
1596
1932
|
`<linearGradient id="${rid}" gradientUnits="userSpaceOnUse" x1="${n(lx)}" y1="${n(ly)}" x2="${n(C2 - lx)}" y2="${n(C2 - ly)}"><stop offset="0" stop-color="#fff" stop-opacity="${dark ? 0.55 : 0.85}"/><stop offset="0.35" stop-color="#fff" stop-opacity="0.08"/><stop offset="0.7" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="${dark ? 0.2 : 0.3}"/></linearGradient>`
|
|
1597
1933
|
);
|
|
1598
1934
|
body.push(`<g clip-path="url(#${clipId})"><path d="${shape}" fill="none" stroke="url(#${rid})" stroke-width="10"/></g>`);
|
|
1599
1935
|
}
|
|
1600
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${size}" height="${size}" viewBox="
|
|
1936
|
+
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
1937
|
}
|
|
1602
1938
|
function cssBlend(mode) {
|
|
1603
1939
|
if (!mode || mode === "normal") return null;
|
|
@@ -1610,11 +1946,11 @@ function cssBlend(mode) {
|
|
|
1610
1946
|
var SHEET_ORDER = ["default", "clear-light", "tinted-light", "dark", "clear-dark", "tinted-dark"];
|
|
1611
1947
|
function renderSheetSvg(doc, opts = {}) {
|
|
1612
1948
|
const cell = opts.cell ?? 320;
|
|
1613
|
-
const
|
|
1949
|
+
const pad2 = Math.round(cell * 0.18);
|
|
1614
1950
|
const label = opts.labels !== false ? Math.round(cell * 0.12) : 0;
|
|
1615
1951
|
const w = cell * 3;
|
|
1616
1952
|
const h = (cell + label) * 2;
|
|
1617
|
-
const icon = cell -
|
|
1953
|
+
const icon = cell - pad2 * 2;
|
|
1618
1954
|
const parts = [];
|
|
1619
1955
|
SHEET_ORDER.forEach((ap, i) => {
|
|
1620
1956
|
const x = i % 3 * cell;
|
|
@@ -1622,7 +1958,7 @@ function renderSheetSvg(doc, opts = {}) {
|
|
|
1622
1958
|
const darkBg = isDarkAppearance(ap);
|
|
1623
1959
|
parts.push(`<rect x="${x}" y="${y}" width="${cell}" height="${cell + label}" fill="${darkBg ? "#14161F" : "#E3E8F0"}"/>`);
|
|
1624
1960
|
const svg = renderSvg(doc, { ...opts, appearance: ap, size: icon, idPrefix: `a${i}_` });
|
|
1625
|
-
parts.push(svg.replace("<svg ", `<svg x="${x +
|
|
1961
|
+
parts.push(svg.replace("<svg ", `<svg x="${x + pad2}" y="${y + pad2}" `).replace(/ width="\d+" height="\d+"/, ` width="${icon}" height="${icon}"`));
|
|
1626
1962
|
if (label)
|
|
1627
1963
|
parts.push(
|
|
1628
1964
|
`<text x="${x + cell / 2}" y="${y + cell + label * 0.15}" text-anchor="middle" font-family="Inter, Helvetica, Arial, sans-serif" font-size="${Math.round(label * 0.5)}" font-weight="600" fill="${darkBg ? "#F2F2F5" : "#1C1C1E"}">${APPEARANCE_LABEL[ap]}</text>`
|
|
@@ -1647,110 +1983,209 @@ async function svgToPng(svg, width) {
|
|
|
1647
1983
|
const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
|
|
1648
1984
|
return new Uint8Array(r.render().asPng());
|
|
1649
1985
|
}
|
|
1986
|
+
async function ensureContentBoxes(doc) {
|
|
1987
|
+
const todo = svgAssetsNeedingBounds(doc);
|
|
1988
|
+
if (!todo.length) return;
|
|
1989
|
+
const { Resvg } = await resvg();
|
|
1990
|
+
for (const a of todo) {
|
|
1991
|
+
try {
|
|
1992
|
+
const svg = new TextDecoder().decode(a.display ?? a.bytes);
|
|
1993
|
+
const png = new Resvg(svg, { fitTo: { mode: "width", value: 256 } }).render().asPng();
|
|
1994
|
+
const bmp = decodePng(new Uint8Array(png));
|
|
1995
|
+
setContentBox(a, bmp ? alphaBounds(bmp.width, bmp.height, bmp.data) : null);
|
|
1996
|
+
} catch {
|
|
1997
|
+
setContentBox(a, null);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
1650
2001
|
async function renderPng(doc, opts = {}) {
|
|
2002
|
+
await ensureContentBoxes(doc);
|
|
1651
2003
|
const size = opts.size ?? 1024;
|
|
1652
2004
|
return svgToPng(renderSvg(doc, { ...opts, size }), size);
|
|
1653
2005
|
}
|
|
1654
2006
|
async function renderSheetPng(doc, opts = {}) {
|
|
2007
|
+
await ensureContentBoxes(doc);
|
|
1655
2008
|
const cell = opts.cell ?? 320;
|
|
1656
2009
|
return svgToPng(renderSheetSvg(doc, opts), cell * 3);
|
|
1657
2010
|
}
|
|
1658
2011
|
function nodeRasterizer(doc, opts = {}) {
|
|
1659
|
-
return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...ro
|
|
2012
|
+
return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...rasterRenderOptions(ro) });
|
|
1660
2013
|
}
|
|
1661
2014
|
|
|
1662
|
-
// ../mcp/src/
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
2015
|
+
// ../mcp/src/cliargs.ts
|
|
2016
|
+
var LEGACY_COMMANDS = ["build", "inspect", "validate", "render", "export", "mcp"];
|
|
2017
|
+
var defOf = (s) => s._zod.def;
|
|
2018
|
+
function unwrap(s) {
|
|
2019
|
+
let optional = false;
|
|
2020
|
+
let cur = s;
|
|
2021
|
+
for (; ; ) {
|
|
2022
|
+
const d = defOf(cur);
|
|
2023
|
+
if (d.type === "optional" || d.type === "default" || d.type === "nullable") {
|
|
2024
|
+
optional = optional || d.type !== "nullable";
|
|
2025
|
+
cur = d.innerType;
|
|
2026
|
+
} else return { schema: cur, optional };
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
function argKind(s) {
|
|
2030
|
+
const { schema } = unwrap(s);
|
|
2031
|
+
const d = defOf(schema);
|
|
2032
|
+
switch (d.type) {
|
|
2033
|
+
case "string":
|
|
2034
|
+
return { type: "string" };
|
|
2035
|
+
case "number":
|
|
2036
|
+
return { type: "number", integer: !!d.checks?.some((c) => c._zod.def.format === "safeint" || c._zod.def.format === "int32") || !!schema.isInt };
|
|
2037
|
+
case "boolean":
|
|
2038
|
+
return { type: "boolean" };
|
|
2039
|
+
case "enum":
|
|
2040
|
+
return { type: "enum", values: Object.values(d.entries ?? {}) };
|
|
2041
|
+
case "array":
|
|
2042
|
+
return { type: "array", of: argKind(d.element) };
|
|
2043
|
+
default:
|
|
2044
|
+
return { type: "json" };
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
function argsOf(t) {
|
|
2048
|
+
return Object.entries(t.input).map(([name, s]) => {
|
|
2049
|
+
const schema = s;
|
|
2050
|
+
const description = schema.description;
|
|
2051
|
+
return {
|
|
2052
|
+
name,
|
|
2053
|
+
flag: `--${name.replace(/_/g, "-")}`,
|
|
2054
|
+
kind: argKind(schema),
|
|
2055
|
+
required: !unwrap(schema).optional,
|
|
2056
|
+
...description ? { description } : {}
|
|
2057
|
+
};
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
var cliCommand = (t) => LEGACY_COMMANDS.includes(t.cli) ? `call ${t.id}` : t.cli;
|
|
2061
|
+
var kindHint = (k) => k.type === "enum" ? k.values.join("|") : k.type === "array" ? `${kindHint(k.of)},\u2026` : k.type === "json" ? "'<json>'" : k.type === "number" ? "<n>" : k.type === "boolean" ? "(flag)" : `<${k.type}>`;
|
|
2062
|
+
function usage(t) {
|
|
2063
|
+
const pos = new Set(t.positional ?? []);
|
|
2064
|
+
const parts = [`fresnel ${cliCommand(t)}`];
|
|
2065
|
+
for (const a of argsOf(t)) {
|
|
2066
|
+
if (pos.has(a.name)) parts.push(a.required ? `<${a.name}>` : `[<${a.name}>]`);
|
|
2067
|
+
}
|
|
2068
|
+
for (const a of argsOf(t)) {
|
|
2069
|
+
if (pos.has(a.name)) continue;
|
|
2070
|
+
const s = a.kind.type === "boolean" ? a.flag : `${a.flag} ${kindHint(a.kind)}`;
|
|
2071
|
+
parts.push(a.required ? s : `[${s}]`);
|
|
2072
|
+
}
|
|
2073
|
+
return parts.join(" ");
|
|
2074
|
+
}
|
|
2075
|
+
function toolHelp(t) {
|
|
2076
|
+
const lines = [`${t.title} (${t.id})`, "", t.description, "", `Usage: ${usage(t)}`, "", "Arguments:"];
|
|
2077
|
+
const pos = new Set(t.positional ?? []);
|
|
2078
|
+
for (const a of argsOf(t)) {
|
|
2079
|
+
const name = pos.has(a.name) ? `<${a.name}>` : a.flag;
|
|
2080
|
+
lines.push(` ${name.padEnd(22)} ${kindHint(a.kind).padEnd(14)} ${a.required ? "required" : "optional"}${a.description ? ` ${a.description}` : ""}`);
|
|
2081
|
+
}
|
|
2082
|
+
lines.push(` ${"--args".padEnd(22)} ${"'<json>'".padEnd(14)} all arguments as one JSON object (flags win)`);
|
|
2083
|
+
if (t.examples.length) {
|
|
2084
|
+
lines.push("", "Examples:");
|
|
2085
|
+
for (const ex of t.examples) lines.push(` # ${ex.note}`, ` ${exampleCommand(t, ex.args)}`);
|
|
2086
|
+
}
|
|
2087
|
+
lines.push("", `Output: JSON on stdout. Verify: ${t.verification.join("; ") || "\u2014"}`);
|
|
2088
|
+
return `${lines.join("\n")}
|
|
1715
2089
|
`;
|
|
1716
2090
|
}
|
|
1717
|
-
var
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
2091
|
+
var shellQuote = (s) => /^[\w@%+=:,./#-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
2092
|
+
function exampleCommand(t, args) {
|
|
2093
|
+
const pos = t.positional ?? [];
|
|
2094
|
+
const parts = [`fresnel ${cliCommand(t)}`];
|
|
2095
|
+
for (const p of pos) if (args[p] !== void 0) parts.push(shellQuote(String(args[p])));
|
|
2096
|
+
for (const a of argsOf(t)) {
|
|
2097
|
+
const v = args[a.name];
|
|
2098
|
+
if (v === void 0 || pos.includes(a.name)) continue;
|
|
2099
|
+
if (a.kind.type === "boolean") parts.push(v ? a.flag : `--no-${a.flag.slice(2)}`);
|
|
2100
|
+
else if (a.kind.type === "array") parts.push(a.flag, shellQuote(v.join(",")));
|
|
2101
|
+
else if (a.kind.type === "json") parts.push(a.flag, shellQuote(JSON.stringify(v)));
|
|
2102
|
+
else parts.push(a.flag, shellQuote(String(v)));
|
|
2103
|
+
}
|
|
2104
|
+
return parts.join(" ");
|
|
2105
|
+
}
|
|
2106
|
+
function convert(k, raw, flag) {
|
|
2107
|
+
switch (k.type) {
|
|
2108
|
+
case "number": {
|
|
2109
|
+
const n2 = Number(raw);
|
|
2110
|
+
if (raw.trim() === "" || Number.isNaN(n2)) throw new Error(`${flag} expects a number, got "${raw}"`);
|
|
2111
|
+
return n2;
|
|
2112
|
+
}
|
|
2113
|
+
case "array":
|
|
2114
|
+
return raw === "" ? [] : raw.split(",").map((x) => convert(k.of, x.trim(), flag));
|
|
2115
|
+
case "json":
|
|
2116
|
+
try {
|
|
2117
|
+
return JSON.parse(raw);
|
|
2118
|
+
} catch {
|
|
2119
|
+
throw new Error(`${flag} expects JSON, got ${raw}`);
|
|
2120
|
+
}
|
|
2121
|
+
default:
|
|
2122
|
+
return raw;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
function parseToolArgv(t, argv) {
|
|
2126
|
+
const infos = argsOf(t);
|
|
2127
|
+
const byFlag = new Map(infos.map((a) => [a.flag, a]));
|
|
2128
|
+
const out = {};
|
|
2129
|
+
let base = {};
|
|
2130
|
+
const positionals = [];
|
|
2131
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2132
|
+
const tok = argv[i];
|
|
2133
|
+
if (!tok.startsWith("--") || tok === "--") {
|
|
2134
|
+
positionals.push(tok);
|
|
2135
|
+
continue;
|
|
2136
|
+
}
|
|
2137
|
+
const eq = tok.indexOf("=");
|
|
2138
|
+
const flag = eq > 0 ? tok.slice(0, eq) : tok;
|
|
2139
|
+
const inline = eq > 0 ? tok.slice(eq + 1) : void 0;
|
|
2140
|
+
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;
|
|
2146
|
+
continue;
|
|
2147
|
+
}
|
|
2148
|
+
const neg = flag.startsWith("--no-") ? byFlag.get(`--${flag.slice(5)}`) : void 0;
|
|
2149
|
+
if (neg && neg.kind.type === "boolean") {
|
|
2150
|
+
out[neg.name] = false;
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
const info = byFlag.get(flag);
|
|
2154
|
+
if (!info) throw new Error(`Unknown option ${flag} for ${t.id}. Run "fresnel ${cliCommand(t)} --help".`);
|
|
2155
|
+
if (info.kind.type === "boolean") {
|
|
2156
|
+
out[info.name] = inline === void 0 ? true : !/^(false|0|no)$/i.test(inline);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
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);
|
|
2162
|
+
}
|
|
2163
|
+
const pos = t.positional ?? [];
|
|
2164
|
+
if (positionals.length > pos.length) throw new Error(`Unexpected argument "${positionals[pos.length]}". Run "fresnel ${cliCommand(t)} --help".`);
|
|
2165
|
+
positionals.forEach((v, i) => {
|
|
2166
|
+
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}>`);
|
|
2168
|
+
});
|
|
2169
|
+
return { ...base, ...out };
|
|
2170
|
+
}
|
|
1726
2171
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
- Fill: "automatic" | "system-light" | "system-dark" | "none" | {"solid": color} | {"automatic-gradient": color} | {"linear-gradient": [color, color], "orientation": {"start": {"x","y"}, "stop": {"x","y"}}}
|
|
1730
|
-
Shorthands: "#336699" \u2192 solid, "gradient:#top,#bottom" \u2192 linear-gradient, "auto-gradient:#color" \u2192 automatic-gradient
|
|
1731
|
-
- Position: {"scale": 1, "translation-in-points": [x, y]} (points on the 1024 canvas, y down, relative to center)
|
|
1732
|
-
- Shadow: {"kind": "none" | "neutral" | "layer-color", "opacity": 0\u20131}
|
|
1733
|
-
- Translucency: {"enabled": true, "value": 0\u20131}
|
|
1734
|
-
- Blend modes: normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, soft-light, hard-light, difference, exclusion, hue, saturation, color, luminosity, plus-darker, plus-lighter
|
|
1735
|
-
`;
|
|
2172
|
+
// ../mcp/src/contract.ts
|
|
2173
|
+
import { z as z3 } from "zod";
|
|
1736
2174
|
|
|
1737
|
-
// ../mcp/src/
|
|
1738
|
-
|
|
2175
|
+
// ../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";
|
|
2178
|
+
import { z as z2 } from "zod";
|
|
2179
|
+
var CONTRACT_VERSION = "1.0";
|
|
2180
|
+
var tool = (def) => def;
|
|
1739
2181
|
var appearanceEnum = z2.enum(APPEARANCES);
|
|
1740
2182
|
var overrideEnum = z2.enum(["dark", "tinted"]);
|
|
2183
|
+
var platformEnum = z2.enum(["iOS", "macOS", "watchOS", "square"]);
|
|
1741
2184
|
var maskEnum = z2.enum(["squircle", "circle", "none"]);
|
|
1742
|
-
var
|
|
1743
|
-
var
|
|
1744
|
-
var
|
|
1745
|
-
|
|
1746
|
-
return async (args) => {
|
|
1747
|
-
try {
|
|
1748
|
-
return await fn(args);
|
|
1749
|
-
} catch (e) {
|
|
1750
|
-
return fail(e);
|
|
1751
|
-
}
|
|
1752
|
-
};
|
|
1753
|
-
}
|
|
2185
|
+
var pathArg = z2.string().describe('Path to the .icon bundle (".icon" is added if missing)');
|
|
2186
|
+
var dryRunArg = z2.boolean().optional().describe("Validate and report the result without writing anything");
|
|
2187
|
+
var loose = (shape) => z2.looseObject(shape);
|
|
2188
|
+
var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.array(z2.string()) }));
|
|
1754
2189
|
function describeProps(props) {
|
|
1755
2190
|
const base = {};
|
|
1756
2191
|
const overrides = {};
|
|
@@ -1782,6 +2217,7 @@ function inspect(doc) {
|
|
|
1782
2217
|
assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
|
|
1783
2218
|
};
|
|
1784
2219
|
}
|
|
2220
|
+
var outline = (doc) => inspect(doc).groups.map((g) => ({ group: g.target, layers: g.layers.map((l) => l.target) }));
|
|
1785
2221
|
function targetLabel(doc, ref) {
|
|
1786
2222
|
if (ref.kind === "document") return "document";
|
|
1787
2223
|
if (ref.kind === "group") return nameOf(findGroup(doc, ref.id));
|
|
@@ -1791,29 +2227,41 @@ function targetLabel(doc, ref) {
|
|
|
1791
2227
|
}
|
|
1792
2228
|
return "?";
|
|
1793
2229
|
}
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
2230
|
+
async function saveFile(ws, out, bytes) {
|
|
2231
|
+
const p = await ws.resolve(out);
|
|
2232
|
+
await mkdir2(dirname2(p), { recursive: true });
|
|
2233
|
+
await writeFile2(p, bytes);
|
|
2234
|
+
return p;
|
|
2235
|
+
}
|
|
2236
|
+
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
|
+
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>';
|
|
2238
|
+
var EX_DOT = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="300" fill="#7FB2FF"/></svg>';
|
|
2239
|
+
var TOOLS = [
|
|
2240
|
+
tool({
|
|
2241
|
+
id: "icon_create",
|
|
2242
|
+
cli: "create",
|
|
2243
|
+
positional: ["path"],
|
|
2244
|
+
title: "Create icon",
|
|
2245
|
+
summary: "Creates a new `.icon` with a background (color, gradient or system) and an optional dark background",
|
|
2246
|
+
description: 'Creates a new .icon bundle (Icon Composer format). background accepts "#RRGGBB", "gradient:#top,#bottom", "auto-gradient:#color", "automatic", "system-light", "system-dark" or a color string like "display-p3:1,0.5,0,1".',
|
|
2247
|
+
role: "create",
|
|
2248
|
+
capabilities: ["bundle", "background", "appearance-overrides", "platforms"],
|
|
2249
|
+
input: {
|
|
2250
|
+
path: z2.string().describe('Bundle path, e.g. "MyApp.icon" (".icon" is added if missing). Relative paths are inside the first allowed root.'),
|
|
2251
|
+
background: z2.string().optional().describe("Background fill for all appearances"),
|
|
2252
|
+
dark_background: z2.string().optional().describe("Background override for the dark appearance"),
|
|
2253
|
+
watch: z2.boolean().optional().describe("Also target watchOS (circular). Default true"),
|
|
2254
|
+
overwrite: z2.boolean().optional().describe("Replace an existing bundle. Default false"),
|
|
2255
|
+
dry_run: dryRunArg
|
|
1815
2256
|
},
|
|
1816
|
-
|
|
2257
|
+
output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
|
|
2258
|
+
mutates_input: true,
|
|
2259
|
+
idempotency_hint: "not-idempotent",
|
|
2260
|
+
supports_dry_run: true,
|
|
2261
|
+
verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
|
|
2262
|
+
requires_visual_verification: false,
|
|
2263
|
+
examples: [{ args: { path: "Demo.icon", background: "gradient:#3D7BFF,#0E2A8A", dark_background: "#101828" }, note: "New icon with a blue gradient and a dark-mode background" }],
|
|
2264
|
+
async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
|
|
1817
2265
|
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)");
|
|
1818
2266
|
const full = await ws.iconPath(path);
|
|
1819
2267
|
const exists = await stat2(full).then(() => true, () => false);
|
|
@@ -1823,63 +2271,101 @@ function createServer(ws) {
|
|
|
1823
2271
|
platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
|
|
1824
2272
|
});
|
|
1825
2273
|
if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
|
|
1826
|
-
await ws.withLock(full, () => saveIcon(full, doc));
|
|
1827
|
-
return
|
|
1828
|
-
}
|
|
1829
|
-
)
|
|
1830
|
-
|
|
1831
|
-
"icon_inspect",
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
2274
|
+
if (!dry_run) await ws.withLock(full, () => saveIcon(full, doc));
|
|
2275
|
+
return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
|
|
2276
|
+
}
|
|
2277
|
+
}),
|
|
2278
|
+
tool({
|
|
2279
|
+
id: "icon_inspect",
|
|
2280
|
+
cli: "inspect",
|
|
2281
|
+
positional: ["path"],
|
|
2282
|
+
title: "Inspect icon",
|
|
2283
|
+
summary: "Lists groups, layers, attributes and every variant",
|
|
2284
|
+
description: "Shows the groups, layers, attributes and per-appearance overrides of an .icon bundle.",
|
|
2285
|
+
role: "inspect",
|
|
2286
|
+
capabilities: ["read"],
|
|
2287
|
+
input: { path: pathArg },
|
|
2288
|
+
output: inspectOutput,
|
|
2289
|
+
mutates_input: false,
|
|
2290
|
+
idempotency_hint: "idempotent",
|
|
2291
|
+
supports_dry_run: false,
|
|
2292
|
+
verification: [],
|
|
2293
|
+
requires_visual_verification: false,
|
|
2294
|
+
examples: [{ args: { path: "Demo.icon" }, note: "List everything in the bundle" }],
|
|
2295
|
+
async run(ws, { path }) {
|
|
1839
2296
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
1840
|
-
return
|
|
1841
|
-
}
|
|
1842
|
-
)
|
|
1843
|
-
|
|
1844
|
-
"icon_add_group",
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2297
|
+
return { data: inspect(await ws.read(full)) };
|
|
2298
|
+
}
|
|
2299
|
+
}),
|
|
2300
|
+
tool({
|
|
2301
|
+
id: "icon_add_group",
|
|
2302
|
+
cli: "add-group",
|
|
2303
|
+
positional: ["path"],
|
|
2304
|
+
title: "Add group",
|
|
2305
|
+
summary: "Adds a group (the unit that carries the glass settings)",
|
|
2306
|
+
description: "Adds a group. Groups carry the Liquid Glass settings (translucency, blur-material, specular, shadow, lighting).",
|
|
2307
|
+
role: "edit",
|
|
2308
|
+
capabilities: ["groups", "glass"],
|
|
2309
|
+
input: {
|
|
2310
|
+
path: pathArg,
|
|
2311
|
+
name: z2.string(),
|
|
2312
|
+
index: z2.number().int().min(0).optional().describe("0 = frontmost (default)"),
|
|
2313
|
+
props: z2.record(z2.string(), z2.any()).optional().describe('Group attributes, e.g. {"translucency": {"enabled": true, "value": 0.5}, "blur-material": 0.3}'),
|
|
2314
|
+
dry_run: dryRunArg
|
|
1854
2315
|
},
|
|
1855
|
-
|
|
2316
|
+
output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
|
|
2317
|
+
mutates_input: true,
|
|
2318
|
+
idempotency_hint: "not-idempotent",
|
|
2319
|
+
supports_dry_run: true,
|
|
2320
|
+
verification: ["icon_inspect lists the new group"],
|
|
2321
|
+
requires_visual_verification: false,
|
|
2322
|
+
examples: [{ args: { path: "Demo.icon", name: "Back", props: { "blur-material": 0.3 } }, note: "A back group with some blur" }],
|
|
2323
|
+
async run(ws, { path, name, index, props, dry_run }) {
|
|
1856
2324
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
1857
|
-
const
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
2325
|
+
const data = await ws.edit(
|
|
2326
|
+
full,
|
|
2327
|
+
(doc) => {
|
|
2328
|
+
const g = addGroup(doc, { name, ...index !== void 0 ? { index } : {} });
|
|
2329
|
+
for (const [k, v] of Object.entries(props ?? {})) setProp(doc, { kind: "group", id: g.id }, k, v);
|
|
2330
|
+
return { added: nameOf(g), groups: doc.groups.map(nameOf) };
|
|
2331
|
+
},
|
|
2332
|
+
{ dryRun: !!dry_run }
|
|
2333
|
+
);
|
|
2334
|
+
return { data };
|
|
2335
|
+
}
|
|
2336
|
+
}),
|
|
2337
|
+
tool({
|
|
2338
|
+
id: "icon_add_layer",
|
|
2339
|
+
cli: "add-layer",
|
|
2340
|
+
positional: ["path"],
|
|
2341
|
+
title: "Add layer",
|
|
2342
|
+
summary: "Adds a layer from inline SVG, an image file or PNG data",
|
|
2343
|
+
description: "Adds an image layer to a group (the group is created if it does not exist). Provide either `svg` (inline SVG markup), `source` (path to an .svg/.png/.jpg file inside the allowed roots) or `png_base64`. Artwork should be a square, unmasked 1024\xD71024 canvas with a transparent background; it is placed at its natural size, centered.",
|
|
2344
|
+
role: "edit",
|
|
2345
|
+
capabilities: ["layers", "images", "glass"],
|
|
2346
|
+
input: {
|
|
2347
|
+
path: pathArg,
|
|
2348
|
+
group: z2.string().describe("Group name or #index. A missing group is created in front (index 0); use icon_move to reorder."),
|
|
2349
|
+
svg: z2.string().optional().describe("Inline SVG markup"),
|
|
2350
|
+
source: z2.string().optional().describe("Image file path"),
|
|
2351
|
+
png_base64: z2.string().optional(),
|
|
2352
|
+
file_name: z2.string().optional().describe('Name to store in Assets/, e.g. "star.svg"'),
|
|
2353
|
+
name: z2.string().optional().describe("Layer name"),
|
|
2354
|
+
index: z2.number().int().min(0).optional().describe("Position inside the group, 0 = frontmost (default)"),
|
|
2355
|
+
props: z2.record(z2.string(), z2.any()).optional().describe('Layer attributes, e.g. {"glass": true, "opacity": 0.8, "fill": "#FFFFFF", "position": {"scale": 0.8, "translation-in-points": [0, -40]}}'),
|
|
2356
|
+
dry_run: dryRunArg
|
|
1881
2357
|
},
|
|
1882
|
-
|
|
2358
|
+
output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
|
|
2359
|
+
mutates_input: true,
|
|
2360
|
+
idempotency_hint: "not-idempotent",
|
|
2361
|
+
supports_dry_run: true,
|
|
2362
|
+
verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
|
|
2363
|
+
requires_visual_verification: true,
|
|
2364
|
+
examples: [
|
|
2365
|
+
{ args: { path: "Demo.icon", group: "Front", svg: EX_STAR, name: "Star" }, note: 'Inline SVG; the "Front" group is created in front' },
|
|
2366
|
+
{ args: { path: "Demo.icon", group: "Back", svg: EX_DOT, name: "Dot", props: { opacity: 0.8 } }, note: "A second layer behind it" }
|
|
2367
|
+
],
|
|
2368
|
+
async run(ws, { path, group, svg, source, png_base64, file_name, name, index, props, dry_run }) {
|
|
1883
2369
|
const given = [svg, source, png_base64].filter((x) => x !== void 0).length;
|
|
1884
2370
|
if (given !== 1) throw new Error("Provide exactly one of svg, source or png_base64");
|
|
1885
2371
|
let bytes;
|
|
@@ -1898,129 +2384,242 @@ function createServer(ws) {
|
|
|
1898
2384
|
const asset = makeAsset(fname, bytes);
|
|
1899
2385
|
if (asset.kind === "other") throw new Error("Unsupported image. Use SVG, PNG or JPEG.");
|
|
1900
2386
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
1901
|
-
const
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2387
|
+
const data = await ws.edit(
|
|
2388
|
+
full,
|
|
2389
|
+
(doc) => {
|
|
2390
|
+
let gid;
|
|
2391
|
+
try {
|
|
2392
|
+
const ref = resolvePath(doc, group);
|
|
2393
|
+
if (ref.kind !== "group") throw new Error("`group` must name a group");
|
|
2394
|
+
gid = ref.id;
|
|
2395
|
+
} catch (e) {
|
|
2396
|
+
const g = group.trim();
|
|
2397
|
+
if (/^#\d+$/.test(g) || g.includes("/") || !/not found/i.test(e.message)) throw e;
|
|
2398
|
+
gid = addGroup(doc, { name: g }).id;
|
|
2399
|
+
}
|
|
2400
|
+
const l = addLayer(doc, gid, asset, {
|
|
2401
|
+
...name ? { name } : {},
|
|
2402
|
+
...index !== void 0 ? { index } : {},
|
|
2403
|
+
...props ? { props } : {}
|
|
2404
|
+
});
|
|
2405
|
+
return {
|
|
2406
|
+
added: targetLabel(doc, { kind: "layer", id: l.id }),
|
|
2407
|
+
size: `${asset.width}\xD7${asset.height}`,
|
|
2408
|
+
warnings: validate(doc).filter((i) => i.ref?.kind === "layer" && i.ref.id === l.id).map((i) => `${i.id}: ${i.message}`)
|
|
2409
|
+
};
|
|
2410
|
+
},
|
|
2411
|
+
{ dryRun: !!dry_run }
|
|
2412
|
+
);
|
|
2413
|
+
return { data };
|
|
2414
|
+
}
|
|
2415
|
+
}),
|
|
2416
|
+
tool({
|
|
2417
|
+
id: "icon_set",
|
|
2418
|
+
cli: "set",
|
|
2419
|
+
positional: ["path"],
|
|
2420
|
+
title: "Set attributes",
|
|
2421
|
+
summary: "Sets attributes for all appearances, or a dark / tinted / platform variant",
|
|
2422
|
+
description: 'Sets attributes on the document, a group or a layer. Pass `appearance` ("dark", or "tinted" = Mono in Icon Composer) and/or `platform` ("iOS", "macOS", "watchOS", "square" = iOS+macOS) to write a variant for only that case. Set a value to null to remove it (or to remove the override when `appearance` is given). See fresnel://schema/icon-json for keys and formats.',
|
|
2423
|
+
role: "edit",
|
|
2424
|
+
capabilities: ["attributes", "appearance-overrides", "platform-variants", "glass"],
|
|
2425
|
+
input: {
|
|
2426
|
+
path: pathArg,
|
|
2427
|
+
target: z2.string().describe('"document", "<Group>" or "<Group>/<Layer>" (names or #index)'),
|
|
2428
|
+
props: z2.record(z2.string(), z2.any()).describe('kebab-case keys, e.g. {"translucency": {"enabled": true, "value": 0.6}, "specular": true}'),
|
|
2429
|
+
appearance: overrideEnum.optional(),
|
|
2430
|
+
platform: platformEnum.optional(),
|
|
2431
|
+
dry_run: dryRunArg
|
|
1938
2432
|
},
|
|
1939
|
-
|
|
2433
|
+
output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
|
|
2434
|
+
mutates_input: true,
|
|
2435
|
+
idempotency_hint: "idempotent",
|
|
2436
|
+
supports_dry_run: true,
|
|
2437
|
+
verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
|
|
2438
|
+
requires_visual_verification: true,
|
|
2439
|
+
examples: [
|
|
2440
|
+
{ args: { path: "Demo.icon", target: "Front", props: { translucency: { enabled: true, value: 0.4 }, specular: true } }, note: "Glass settings on a group" },
|
|
2441
|
+
{ args: { path: "Demo.icon", target: "Front/Star", props: { fill: "#FFD60A" }, appearance: "dark" }, note: "A dark-mode color for one layer" }
|
|
2442
|
+
],
|
|
2443
|
+
async run(ws, { path, target, props, appearance, platform, dry_run }) {
|
|
1940
2444
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
1941
|
-
const
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2445
|
+
const data = await ws.edit(
|
|
2446
|
+
full,
|
|
2447
|
+
(doc) => {
|
|
2448
|
+
const ref = resolvePath(doc, target);
|
|
2449
|
+
const variant = appearance || platform ? { ...appearance ? { appearance } : {}, ...platform ? { idiom: platform } : {} } : void 0;
|
|
2450
|
+
for (const [k, v] of Object.entries(props)) setProp(doc, ref, k, v, variant);
|
|
2451
|
+
return { target: targetLabel(doc, ref), now: describeProps(nodeOf(doc, ref).props) };
|
|
2452
|
+
},
|
|
2453
|
+
{ dryRun: !!dry_run }
|
|
2454
|
+
);
|
|
2455
|
+
return { data };
|
|
2456
|
+
}
|
|
2457
|
+
}),
|
|
2458
|
+
tool({
|
|
2459
|
+
id: "icon_move",
|
|
2460
|
+
cli: "move",
|
|
2461
|
+
positional: ["path"],
|
|
2462
|
+
title: "Reorder",
|
|
2463
|
+
summary: "Reorders groups and layers, or moves a layer to another group",
|
|
2464
|
+
description: "Moves a group or layer to a new index (0 = frontmost). Layers can move to another group with to_group.",
|
|
2465
|
+
role: "edit",
|
|
2466
|
+
capabilities: ["groups", "layers", "order"],
|
|
2467
|
+
input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
|
|
2468
|
+
output: loose({ groups: groupsOutline }),
|
|
2469
|
+
mutates_input: true,
|
|
2470
|
+
idempotency_hint: "idempotent",
|
|
2471
|
+
supports_dry_run: true,
|
|
2472
|
+
verification: ["`groups` lists the new order (front to back)"],
|
|
2473
|
+
requires_visual_verification: false,
|
|
2474
|
+
examples: [{ args: { path: "Demo.icon", target: "Back", index: 1 }, note: "Keep the Back group behind Front" }],
|
|
2475
|
+
async run(ws, { path, target, index, to_group, dry_run }) {
|
|
1959
2476
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
1960
|
-
const
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2477
|
+
const data = await ws.edit(
|
|
2478
|
+
full,
|
|
2479
|
+
(doc) => {
|
|
2480
|
+
const ref = resolvePath(doc, target);
|
|
2481
|
+
let gid;
|
|
2482
|
+
if (to_group) {
|
|
2483
|
+
const g = resolvePath(doc, to_group);
|
|
2484
|
+
if (g.kind !== "group") throw new Error("to_group must be a group");
|
|
2485
|
+
gid = g.id;
|
|
2486
|
+
}
|
|
2487
|
+
moveNode(doc, ref, index, gid);
|
|
2488
|
+
return { groups: outline(doc) };
|
|
2489
|
+
},
|
|
2490
|
+
{ dryRun: !!dry_run }
|
|
2491
|
+
);
|
|
2492
|
+
return { data };
|
|
2493
|
+
}
|
|
2494
|
+
}),
|
|
2495
|
+
tool({
|
|
2496
|
+
id: "icon_align",
|
|
2497
|
+
cli: "align",
|
|
2498
|
+
positional: ["path"],
|
|
2499
|
+
title: "Align or distribute",
|
|
2500
|
+
summary: "Aligns or distributes groups or layers (to each other or to the canvas)",
|
|
2501
|
+
description: 'Aligns groups or layers by their bounding boxes (left, hcenter, right, top, vcenter, bottom), or spaces 3+ items evenly (distribute-horizontal, distribute-vertical). With one target, or basis "canvas", items align to the 1024 pt canvas. Only positions change.',
|
|
2502
|
+
role: "edit",
|
|
2503
|
+
capabilities: ["layout", "align", "distribute", "platform-variants"],
|
|
2504
|
+
input: {
|
|
2505
|
+
path: pathArg,
|
|
2506
|
+
targets: z2.array(z2.string()).min(1).describe('"<Group>" or "<Group>/<Layer>" (names or #index); all of the same kind'),
|
|
2507
|
+
how: z2.enum(["left", "hcenter", "right", "top", "vcenter", "bottom", "distribute-horizontal", "distribute-vertical"]),
|
|
2508
|
+
basis: z2.enum(["selection", "canvas"]).optional().describe("Default: selection (canvas when there is one target)"),
|
|
2509
|
+
platform: platformEnum.optional().describe("Write the positions as a platform variant"),
|
|
2510
|
+
dry_run: dryRunArg
|
|
1975
2511
|
},
|
|
1976
|
-
|
|
2512
|
+
output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
|
|
2513
|
+
mutates_input: true,
|
|
2514
|
+
idempotency_hint: "idempotent",
|
|
2515
|
+
supports_dry_run: true,
|
|
2516
|
+
verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
|
|
2517
|
+
requires_visual_verification: true,
|
|
2518
|
+
examples: [{ args: { path: "Demo.icon", targets: ["Front/Star"], how: "vcenter" }, note: "Center one layer vertically on the canvas" }],
|
|
2519
|
+
async run(ws, { path, targets, how, basis, platform, dry_run }) {
|
|
1977
2520
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
1978
|
-
const
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
if (
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2521
|
+
const data = await ws.edit(
|
|
2522
|
+
full,
|
|
2523
|
+
(doc) => {
|
|
2524
|
+
const refs = targets.map((t) => resolvePath(doc, t));
|
|
2525
|
+
if (refs.some((r) => r.kind === "document")) throw new Error("targets must be groups or layers");
|
|
2526
|
+
if (new Set(refs.map((r) => r.kind)).size > 1) throw new Error("targets must all be groups or all be layers");
|
|
2527
|
+
const ctx = { appearance: "default", idiom: platform === "square" || !platform ? "iOS" : platform };
|
|
2528
|
+
const variant = platform ? { idiom: platform } : void 0;
|
|
2529
|
+
const moved = how.startsWith("distribute-") ? distributeNodes(doc, refs, how === "distribute-horizontal" ? "horizontal" : "vertical", ctx, { variant }) : alignNodes(doc, refs, how, ctx, { ...basis ? { basis } : {}, variant });
|
|
2530
|
+
return { moved, positions: refs.map((r, i) => ({ target: targets[i], box: boxOf(doc, r, ctx) })) };
|
|
2531
|
+
},
|
|
2532
|
+
{ dryRun: !!dry_run }
|
|
2533
|
+
);
|
|
2534
|
+
return { data };
|
|
2535
|
+
}
|
|
2536
|
+
}),
|
|
2537
|
+
tool({
|
|
2538
|
+
id: "icon_remove",
|
|
2539
|
+
cli: "remove",
|
|
2540
|
+
positional: ["path"],
|
|
2541
|
+
title: "Remove group or layer",
|
|
2542
|
+
summary: "Deletes a group or layer (unused assets go too)",
|
|
2543
|
+
description: "Removes a group or a layer. Unused assets are deleted from Assets/.",
|
|
2544
|
+
role: "edit",
|
|
2545
|
+
capabilities: ["groups", "layers"],
|
|
2546
|
+
input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
|
|
2547
|
+
output: loose({ removed: z2.string(), groups: groupsOutline }),
|
|
2548
|
+
mutates_input: true,
|
|
2549
|
+
idempotency_hint: "not-idempotent",
|
|
2550
|
+
destructive: true,
|
|
2551
|
+
supports_dry_run: true,
|
|
2552
|
+
verification: ["`groups` no longer lists the target"],
|
|
2553
|
+
requires_visual_verification: false,
|
|
2554
|
+
examples: [{ args: { path: "Demo.icon", target: "Back/Dot", dry_run: true }, note: "Preview a removal without writing" }],
|
|
2555
|
+
async run(ws, { path, target, dry_run }) {
|
|
2556
|
+
const full = await ws.iconPath(path, { mustExist: true });
|
|
2557
|
+
const data = await ws.edit(
|
|
2558
|
+
full,
|
|
2559
|
+
(doc) => {
|
|
2560
|
+
const ref = resolvePath(doc, target);
|
|
2561
|
+
const label = targetLabel(doc, ref);
|
|
2562
|
+
removeNode(doc, ref);
|
|
2563
|
+
return { removed: label, groups: outline(doc) };
|
|
2564
|
+
},
|
|
2565
|
+
{ dryRun: !!dry_run }
|
|
2566
|
+
);
|
|
2567
|
+
return { data };
|
|
2568
|
+
}
|
|
2569
|
+
}),
|
|
2570
|
+
tool({
|
|
2571
|
+
id: "icon_validate",
|
|
2572
|
+
cli: "validate",
|
|
2573
|
+
positional: ["path"],
|
|
2574
|
+
title: "Validate",
|
|
2575
|
+
summary: "Runs the icon checks (schema and Apple HIG)",
|
|
2576
|
+
description: "Checks the icon against the schema and Apple HIG recommendations (square unmasked layers, opaque background, no text, variants, \u2026).",
|
|
2577
|
+
role: "check",
|
|
2578
|
+
capabilities: ["validate", "hig"],
|
|
2579
|
+
input: { path: pathArg },
|
|
2580
|
+
output: loose({
|
|
2581
|
+
summary: z2.looseObject({ errors: z2.number(), warnings: z2.number(), infos: z2.number() }),
|
|
2582
|
+
issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
|
|
2583
|
+
}),
|
|
2584
|
+
mutates_input: false,
|
|
2585
|
+
idempotency_hint: "idempotent",
|
|
2586
|
+
supports_dry_run: false,
|
|
2587
|
+
verification: ['No issue with severity "error"'],
|
|
2588
|
+
requires_visual_verification: false,
|
|
2589
|
+
examples: [{ args: { path: "Demo.icon" }, note: "Run the V-01\u2026V-10 checks" }],
|
|
2590
|
+
async run(ws, { path }) {
|
|
2001
2591
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2002
2592
|
const issues = validate(await ws.read(full)).map(({ ref: _ref, ...i }) => i);
|
|
2003
|
-
return
|
|
2004
|
-
}
|
|
2005
|
-
)
|
|
2006
|
-
|
|
2007
|
-
"icon_render",
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2593
|
+
return { data: { summary: summarizeIssues(issues), issues } };
|
|
2594
|
+
}
|
|
2595
|
+
}),
|
|
2596
|
+
tool({
|
|
2597
|
+
id: "icon_render",
|
|
2598
|
+
cli: "render",
|
|
2599
|
+
positional: ["path"],
|
|
2600
|
+
title: "Render preview",
|
|
2601
|
+
summary: "Returns a PNG of one appearance as an image the model can see",
|
|
2602
|
+
description: "Renders an approximate preview PNG of one appearance and returns it as an image. Optionally saves it with `out`.",
|
|
2603
|
+
role: "render",
|
|
2604
|
+
capabilities: ["preview", "appearances", "platforms"],
|
|
2605
|
+
input: {
|
|
2606
|
+
path: pathArg,
|
|
2607
|
+
appearance: appearanceEnum.optional().describe('Default: "default". Mono in Icon Composer = clear-light / clear-dark / tinted-light / tinted-dark'),
|
|
2608
|
+
platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
|
|
2609
|
+
size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
|
|
2610
|
+
mask: maskEnum.optional(),
|
|
2611
|
+
tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
|
|
2612
|
+
light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
|
|
2613
|
+
out: z2.string().optional().describe("Save the PNG to this path")
|
|
2022
2614
|
},
|
|
2023
|
-
|
|
2615
|
+
output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
|
|
2616
|
+
mutates_input: false,
|
|
2617
|
+
idempotency_hint: "idempotent",
|
|
2618
|
+
supports_dry_run: false,
|
|
2619
|
+
verification: ["Look at the returned image"],
|
|
2620
|
+
requires_visual_verification: true,
|
|
2621
|
+
examples: [{ args: { path: "Demo.icon", appearance: "dark", size: 128, out: "Demo-dark.png" }, note: "Dark preview saved as a file" }],
|
|
2622
|
+
async run(ws, { path, appearance, platform, size, mask, tint, light_angle, out }) {
|
|
2024
2623
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2025
2624
|
const doc = await ws.read(full);
|
|
2026
2625
|
const png = await renderPng(doc, {
|
|
@@ -2032,52 +2631,75 @@ function createServer(ws) {
|
|
|
2032
2631
|
...light_angle !== void 0 ? { lightAngle: light_angle } : {}
|
|
2033
2632
|
});
|
|
2034
2633
|
const saved = out ? await saveFile(ws, out, png) : void 0;
|
|
2035
|
-
return
|
|
2036
|
-
}
|
|
2037
|
-
)
|
|
2038
|
-
|
|
2039
|
-
"icon_render_sheet",
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2634
|
+
return { data: { appearance: appearance ?? "default", ...saved ? { saved } : {} }, image: png };
|
|
2635
|
+
}
|
|
2636
|
+
}),
|
|
2637
|
+
tool({
|
|
2638
|
+
id: "icon_render_sheet",
|
|
2639
|
+
cli: "render-sheet",
|
|
2640
|
+
positional: ["path"],
|
|
2641
|
+
title: "Render all appearances",
|
|
2642
|
+
summary: "Returns all six appearances side by side as one PNG",
|
|
2643
|
+
description: "Renders the six appearances (Default, Clear light, Tinted light / Dark, Clear dark, Tinted dark) side by side and returns the PNG.",
|
|
2644
|
+
role: "render",
|
|
2645
|
+
capabilities: ["preview", "appearances"],
|
|
2646
|
+
input: {
|
|
2647
|
+
path: pathArg,
|
|
2648
|
+
cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
|
|
2649
|
+
tint: z2.string().optional(),
|
|
2650
|
+
out: z2.string().optional()
|
|
2050
2651
|
},
|
|
2051
|
-
|
|
2652
|
+
output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
|
|
2653
|
+
mutates_input: false,
|
|
2654
|
+
idempotency_hint: "idempotent",
|
|
2655
|
+
supports_dry_run: false,
|
|
2656
|
+
verification: ["Look at the returned image: every appearance should stay legible"],
|
|
2657
|
+
requires_visual_verification: true,
|
|
2658
|
+
examples: [{ args: { path: "Demo.icon", cell: 120 }, note: "Compare all six appearances" }],
|
|
2659
|
+
async run(ws, { path, cell, tint, out }) {
|
|
2052
2660
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2053
2661
|
const png = await renderSheetPng(await ws.read(full), { cell: cell ?? 240, ...tint ? { tint } : {} });
|
|
2054
2662
|
const saved = out ? await saveFile(ws, out, png) : void 0;
|
|
2055
|
-
return
|
|
2056
|
-
}
|
|
2057
|
-
)
|
|
2058
|
-
|
|
2059
|
-
"icon_export",
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2663
|
+
return { data: { appearances: [...APPEARANCES], ...saved ? { saved } : {} }, image: png };
|
|
2664
|
+
}
|
|
2665
|
+
}),
|
|
2666
|
+
tool({
|
|
2667
|
+
id: "icon_export",
|
|
2668
|
+
cli: "export",
|
|
2669
|
+
positional: ["path"],
|
|
2670
|
+
title: "Export",
|
|
2671
|
+
summary: "Writes `.icon`, `.icns`, `AppIcon.appiconset` and PNG sets",
|
|
2672
|
+
description: 'Exports the icon. Formats: "icon" (copy of the .icon bundle for Xcode 26+ / Icon Composer), "icns" (macOS), "appiconset" (Xcode asset catalog with Dark and Tinted variants), "png" (PNG set per appearance and size). Raster formats use fresnel\u2019s approximate renderer. Assets.car requires Apple\u2019s actool on macOS and is not produced here.',
|
|
2673
|
+
role: "export",
|
|
2674
|
+
capabilities: ["export", "icns", "appiconset", "png"],
|
|
2675
|
+
input: {
|
|
2676
|
+
path: pathArg,
|
|
2677
|
+
out_dir: z2.string(),
|
|
2678
|
+
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")
|
|
2070
2681
|
},
|
|
2071
|
-
|
|
2682
|
+
output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
|
|
2683
|
+
mutates_input: false,
|
|
2684
|
+
idempotency_hint: "idempotent",
|
|
2685
|
+
supports_dry_run: false,
|
|
2686
|
+
verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
|
|
2687
|
+
requires_visual_verification: false,
|
|
2688
|
+
examples: [{ args: { path: "Demo.icon", out_dir: "build", formats: ["icon", "icns", "appiconset"] }, note: "Everything Xcode needs" }],
|
|
2689
|
+
async run(ws, { path, out_dir, formats, sizes, appearances }) {
|
|
2072
2690
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2073
2691
|
const outDir = await ws.resolve(out_dir);
|
|
2074
2692
|
const doc = await ws.read(full);
|
|
2075
2693
|
const name = basename(full).replace(/\.icon(\.zip)?$/, "");
|
|
2076
2694
|
const raster = nodeRasterizer(doc);
|
|
2695
|
+
const missing = unrenderableLayers(doc);
|
|
2696
|
+
if (missing.length && formats.some((f) => f !== "icon"))
|
|
2697
|
+
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
2698
|
const written = [];
|
|
2078
2699
|
if (formats.includes("icon")) {
|
|
2079
|
-
const
|
|
2080
|
-
|
|
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)));
|
|
2081
2703
|
}
|
|
2082
2704
|
if (formats.includes("icns")) {
|
|
2083
2705
|
const p = join2(outDir, `${name}.icns`);
|
|
@@ -2085,17 +2707,211 @@ function createServer(ws) {
|
|
|
2085
2707
|
await writeFile2(p, await buildIcns(raster));
|
|
2086
2708
|
written.push(p);
|
|
2087
2709
|
}
|
|
2088
|
-
if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster)));
|
|
2710
|
+
if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
|
|
2089
2711
|
if (formats.includes("png"))
|
|
2090
2712
|
written.push(
|
|
2091
|
-
...await writeFiles(
|
|
2092
|
-
join2(outDir, `${name}-png`),
|
|
2093
|
-
await buildPngSet(raster, appearances ?? [...APPEARANCES], sizes ?? [1024, 512, 256, 128, 64, 32, 16])
|
|
2094
|
-
)
|
|
2713
|
+
...await writeFiles(join2(outDir, `${name}-png`), await buildPngSet(raster, appearances ?? [...APPEARANCES], sizes ?? [1024, 512, 256, 128, 64, 32, 16]))
|
|
2095
2714
|
);
|
|
2096
|
-
return
|
|
2097
|
-
}
|
|
2715
|
+
return { data: { out_dir: outDir, files: written.length, written } };
|
|
2716
|
+
}
|
|
2717
|
+
})
|
|
2718
|
+
];
|
|
2719
|
+
var toolById = (id) => TOOLS.find((t) => t.id === id);
|
|
2720
|
+
var toolByCli = (name) => TOOLS.find((t) => t.cli === name);
|
|
2721
|
+
async function runTool(ws, t, args) {
|
|
2722
|
+
const parsed = z2.object(t.input).strict().safeParse(args ?? {});
|
|
2723
|
+
if (!parsed.success) throw new Error(`Invalid arguments for ${t.id}: ${z2.prettifyError(parsed.error)}`);
|
|
2724
|
+
return t.run(ws, parsed.data);
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
// ../mcp/src/version.ts
|
|
2728
|
+
var VERSION = "0.2.0";
|
|
2729
|
+
|
|
2730
|
+
// ../mcp/src/contract.ts
|
|
2731
|
+
var RESOURCES = [
|
|
2732
|
+
{ uri: "fresnel://guide/icon-design", title: "Liquid Glass icon design guide" },
|
|
2733
|
+
{ uri: "fresnel://schema/icon-json", title: "icon.json attribute reference" },
|
|
2734
|
+
{ uri: "fresnel://contract", title: "SPEC contract (this document)" }
|
|
2735
|
+
];
|
|
2736
|
+
var PROMPTS = [{ name: "design_app_icon", title: "Design an app icon" }];
|
|
2737
|
+
var LEGACY_HELP = {
|
|
2738
|
+
build: "fresnel build <spec.json> -o <Name.icon> \u2014 build a bundle from a JSON spec (fixtures/sample.spec.json)",
|
|
2739
|
+
inspect: "fresnel inspect <Name.icon> \u2014 same output as icon_inspect",
|
|
2740
|
+
validate: "fresnel validate <Name.icon> [--json] [--strict] \u2014 exit 1 on errors (--strict: also warnings)",
|
|
2741
|
+
render: "fresnel render <Name.icon> [-a <appearance>] [-s <px>] [--mask \u2026] [--tint \u2026] [--sheet] [-o out.png] \u2014 writes a PNG file",
|
|
2742
|
+
export: "fresnel export <Name.icon> -o <dir> [--formats \u2026] [--sizes \u2026] [--appearances \u2026] [--tint \u2026] [--force]",
|
|
2743
|
+
mcp: "fresnel mcp [--root <dir>]\u2026 \u2014 start the MCP server on stdio"
|
|
2744
|
+
};
|
|
2745
|
+
var jsonSchema = (s, io) => {
|
|
2746
|
+
const { $schema: _drop, ...rest } = z3.toJSONSchema(s, { io, unrepresentable: "any" });
|
|
2747
|
+
return rest;
|
|
2748
|
+
};
|
|
2749
|
+
function attrTable(attrs) {
|
|
2750
|
+
return Object.fromEntries(
|
|
2751
|
+
Object.entries(attrs).map(([k, a]) => [k, { schema: jsonSchema(a.schema, "input"), default: a.default ?? null, description: a.description }])
|
|
2098
2752
|
);
|
|
2753
|
+
}
|
|
2754
|
+
function toolSpec(t) {
|
|
2755
|
+
return {
|
|
2756
|
+
id: t.id,
|
|
2757
|
+
title: t.title,
|
|
2758
|
+
summary: t.summary,
|
|
2759
|
+
description: t.description,
|
|
2760
|
+
role: t.role,
|
|
2761
|
+
capabilities: t.capabilities,
|
|
2762
|
+
input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
|
|
2763
|
+
output_schema: jsonSchema(t.output, "output"),
|
|
2764
|
+
mutates_input: t.mutates_input,
|
|
2765
|
+
idempotency_hint: t.idempotency_hint,
|
|
2766
|
+
destructive: !!t.destructive,
|
|
2767
|
+
supports_dry_run: t.supports_dry_run,
|
|
2768
|
+
supports_json: true,
|
|
2769
|
+
returns_image: t.role === "render",
|
|
2770
|
+
verification: t.verification,
|
|
2771
|
+
requires_visual_verification: t.requires_visual_verification,
|
|
2772
|
+
cli: usage(t),
|
|
2773
|
+
examples: t.examples.map((e) => ({ ...e, cli: exampleCommand(t, e.args) }))
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
function buildContract() {
|
|
2777
|
+
const capability_map = {};
|
|
2778
|
+
for (const t of TOOLS) for (const c of t.capabilities) (capability_map[c] ??= []).push(t.id);
|
|
2779
|
+
return {
|
|
2780
|
+
contract_version: CONTRACT_VERSION,
|
|
2781
|
+
name: "fresnel",
|
|
2782
|
+
version: VERSION,
|
|
2783
|
+
description: "Create, edit, preview and export Apple Liquid Glass app icons (.icon bundles, the Icon Composer format).",
|
|
2784
|
+
conventions: {
|
|
2785
|
+
targets: '"document", "<Group>" or "<Group>/<Layer>" (names or #index; index 0 is frontmost)',
|
|
2786
|
+
order: "groups and layers are listed front to back",
|
|
2787
|
+
errors: "MCP: isError with a readable message. CLI: message on stderr, exit code 1",
|
|
2788
|
+
cli: `argument names are the input names with "_" \u2192 "-"; arrays are comma separated; objects are JSON; --args '<json>' passes everything at once`,
|
|
2789
|
+
previews: "approximations of Apple's renderer; the final check is Icon Composer / Xcode 26 on a Mac"
|
|
2790
|
+
},
|
|
2791
|
+
stability: {
|
|
2792
|
+
compatible: ["new tools", "new optional arguments", "new output keys", "new enum values", "wording of descriptions, examples and verification"],
|
|
2793
|
+
breaking: ["removing or renaming a tool or argument", "optional \u2192 required", "changing an argument type", "removing an enum value", "removing an output key"],
|
|
2794
|
+
rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
|
|
2795
|
+
},
|
|
2796
|
+
tools: TOOLS.map(toolSpec),
|
|
2797
|
+
capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a.localeCompare(b))),
|
|
2798
|
+
resources: RESOURCES,
|
|
2799
|
+
prompts: PROMPTS,
|
|
2800
|
+
icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
|
|
2801
|
+
cli_legacy_commands: LEGACY_HELP
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// ../mcp/src/tools.ts
|
|
2806
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2807
|
+
import { z as z4 } from "zod";
|
|
2808
|
+
|
|
2809
|
+
// ../mcp/src/guide.ts
|
|
2810
|
+
var ICON_GUIDE = `# Designing Liquid Glass app icons (iOS, iPadOS, macOS, watchOS)
|
|
2811
|
+
|
|
2812
|
+
Source: Apple Human Interface Guidelines, "App icons"
|
|
2813
|
+
https://developer.apple.com/design/human-interface-guidelines/app-icons
|
|
2814
|
+
|
|
2815
|
+
## Structure
|
|
2816
|
+
- 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.
|
|
2817
|
+
- Canvas: 1024\xD71024 for iOS/iPadOS/macOS (masked to a rounded rectangle) and 1088\xD71088 for watchOS (masked to a circle).
|
|
2818
|
+
- Appearances: Default, Dark, Clear light, Clear dark, Tinted light, Tinted dark. If you don't provide a variant, the system generates one.
|
|
2819
|
+
- 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).
|
|
2820
|
+
- In Icon Composer the appearances are Default, Dark and Mono (Mono covers clear and tinted); icon.json stores Mono overrides under the "tinted" key.
|
|
2821
|
+
|
|
2822
|
+
## Layers
|
|
2823
|
+
- Provide square, unmasked layers. Never bake in rounded corners; the system masks the icon, and pre-masked layers break the specular highlights and leave jagged edges.
|
|
2824
|
+
- Prefer clearly defined edges. Avoid soft or feathered shapes.
|
|
2825
|
+
- Import layers fully opaque and set transparency in the tool. Varying opacity across foreground layers adds depth.
|
|
2826
|
+
- Prefer vector art (SVG/PDF). Convert text to outlines. Use PNG for raster art and mesh gradients.
|
|
2827
|
+
- Don't bake in highlights, drop shadows, bevels, blurs or glows. The system draws dynamic effects.
|
|
2828
|
+
|
|
2829
|
+
## Design
|
|
2830
|
+
- Keep it simple. Use one core idea expressed with a minimal number of filled, overlapping shapes.
|
|
2831
|
+
- Prefer a simple background, such as a solid color or gradient. A background image must be full-bleed and opaque.
|
|
2832
|
+
- Keep primary content centered so masking (especially circles on watchOS) doesn't truncate it.
|
|
2833
|
+
- Avoid text unless it's essential. Avoid photos, UI screenshots and replicas of Apple hardware.
|
|
2834
|
+
- Avoid very thin lines and sharp corners; they disappear at small sizes.
|
|
2835
|
+
|
|
2836
|
+
## Appearances
|
|
2837
|
+
- Keep the same core features across all appearances. Don't swap elements in and out.
|
|
2838
|
+
- Base the dark icon on the light one, using complementary, less bright colors. Colored backgrounds give the most contrast in dark mode.
|
|
2839
|
+
- Dark, clear and tinted icons are more subdued. Check that the icon stays legible and recognizable in all of them.
|
|
2840
|
+
|
|
2841
|
+
## fresnel workflow
|
|
2842
|
+
1. icon_create with a background (e.g. "gradient:#3D7BFF,#0E2A8A").
|
|
2843
|
+
2. Draw each foreground element as its own 1024\xD71024 SVG (transparent background, centered artwork) and add it with icon_add_layer.
|
|
2844
|
+
3. Use groups for depth: put elements that should share glass settings in one group. Index 0 is the frontmost.
|
|
2845
|
+
4. Set glass on layers that should catch highlights, then tune the group: translucency, blur-material, specular, shadow.
|
|
2846
|
+
5. Add Dark and Mono ("tinted") overrides with icon_set (appearance: "dark" or "tinted"), and platform variants (platform: "watchOS", \u2026) where layout differs.
|
|
2847
|
+
6. Check icon_render_sheet and icon_validate, then icon_export.
|
|
2848
|
+
`;
|
|
2849
|
+
function table(title, defs) {
|
|
2850
|
+
const rows = Object.entries(defs).map(([k, d]) => `| \`${k}\` | ${d.description} | \`${JSON.stringify(d.default)}\` |`);
|
|
2851
|
+
return `### ${title}
|
|
2852
|
+
|
|
2853
|
+
| key | meaning | default when absent |
|
|
2854
|
+
|---|---|---|
|
|
2855
|
+
${rows.join("\n")}
|
|
2856
|
+
`;
|
|
2857
|
+
}
|
|
2858
|
+
var SCHEMA_GUIDE = `# icon.json attributes understood by fresnel
|
|
2859
|
+
|
|
2860
|
+
The format isn't documented by Apple. This reference is reverse-engineered (glassmith, icon-composer-template, real Icon Composer files).
|
|
2861
|
+
Any attribute can be overridden per appearance with icon_set(..., appearance: "dark" | "tinted").
|
|
2862
|
+
Unknown attributes are accepted and preserved.
|
|
2863
|
+
|
|
2864
|
+
${table("Document", DOCUMENT_ATTRS)}
|
|
2865
|
+
${table("Group", GROUP_ATTRS)}
|
|
2866
|
+
${table("Layer", LAYER_ATTRS)}
|
|
2867
|
+
|
|
2868
|
+
### Value formats
|
|
2869
|
+
- Color: "#RRGGBB" (stored as srgb) or Icon Composer strings: "display-p3:r,g,b,a", "srgb:r,g,b,a", "extended-gray:l,a"
|
|
2870
|
+
- Fill: "automatic" | "system-light" | "system-dark" | "none" | {"solid": color} | {"automatic-gradient": color} | {"linear-gradient": [color, color], "orientation": {"start": {"x","y"}, "stop": {"x","y"}}}
|
|
2871
|
+
Shorthands: "#336699" \u2192 solid, "gradient:#top,#bottom" \u2192 linear-gradient, "auto-gradient:#color" \u2192 automatic-gradient
|
|
2872
|
+
- Position: {"scale": 1, "translation-in-points": [x, y]} (points on the 1024 canvas, y down, relative to center)
|
|
2873
|
+
- Shadow: {"kind": "none" | "neutral" | "layer-color", "opacity": 0\u20131}
|
|
2874
|
+
- Translucency: {"enabled": true, "value": 0\u20131}
|
|
2875
|
+
- Blend modes: normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, soft-light, hard-light, difference, exclusion, hue, saturation, color, luminosity, plus-darker, plus-lighter
|
|
2876
|
+
`;
|
|
2877
|
+
|
|
2878
|
+
// ../mcp/src/tools.ts
|
|
2879
|
+
var text = (v) => ({ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) });
|
|
2880
|
+
var fail = (e) => ({ content: [text(`Error: ${e.message}`)], isError: true });
|
|
2881
|
+
var b642 = (u) => Buffer.from(u).toString("base64");
|
|
2882
|
+
function createServer(ws) {
|
|
2883
|
+
const server = new McpServer(
|
|
2884
|
+
{ name: "fresnel", version: VERSION },
|
|
2885
|
+
{
|
|
2886
|
+
instructions: 'fresnel edits Apple Liquid Glass app icons stored as .icon bundles (the Icon Composer format). Typical flow: icon_create \u2192 icon_add_layer (one 1024\xD71024 SVG per element) \u2192 icon_set (glass, translucency, dark/tinted overrides) \u2192 icon_render_sheet to look at all appearances \u2192 icon_validate \u2192 icon_export. Read fresnel://guide/icon-design for Apple design rules. Targets are "document", "<Group>" or "<Group>/<Layer>" (names or #index; index 0 is frontmost). Previews are approximations of the system renderer.'
|
|
2887
|
+
}
|
|
2888
|
+
);
|
|
2889
|
+
for (const t of TOOLS) {
|
|
2890
|
+
server.registerTool(
|
|
2891
|
+
t.id,
|
|
2892
|
+
{
|
|
2893
|
+
title: t.title,
|
|
2894
|
+
description: t.description,
|
|
2895
|
+
inputSchema: t.input,
|
|
2896
|
+
outputSchema: t.output,
|
|
2897
|
+
annotations: {
|
|
2898
|
+
readOnlyHint: !t.mutates_input,
|
|
2899
|
+
destructiveHint: !!t.destructive,
|
|
2900
|
+
idempotentHint: t.idempotency_hint === "idempotent",
|
|
2901
|
+
openWorldHint: false
|
|
2902
|
+
}
|
|
2903
|
+
},
|
|
2904
|
+
async (args) => {
|
|
2905
|
+
try {
|
|
2906
|
+
const { data, image } = await runTool(ws, t, args);
|
|
2907
|
+
const content = image ? [{ type: "image", data: b642(image), mimeType: "image/png" }, text(data)] : [text(data)];
|
|
2908
|
+
return { content, structuredContent: data };
|
|
2909
|
+
} catch (e) {
|
|
2910
|
+
return fail(e);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
);
|
|
2914
|
+
}
|
|
2099
2915
|
server.registerResource(
|
|
2100
2916
|
"icon-design-guide",
|
|
2101
2917
|
"fresnel://guide/icon-design",
|
|
@@ -2108,15 +2924,21 @@ function createServer(ws) {
|
|
|
2108
2924
|
{ title: "icon.json attribute reference", description: "Keys, value formats and defaults fresnel understands", mimeType: "text/markdown" },
|
|
2109
2925
|
async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: SCHEMA_GUIDE }] })
|
|
2110
2926
|
);
|
|
2927
|
+
server.registerResource(
|
|
2928
|
+
"contract",
|
|
2929
|
+
"fresnel://contract",
|
|
2930
|
+
{ title: "SPEC contract", description: "Machine-readable contract: every tool with input/output JSON Schema, roles, verification and examples, plus the icon.json attribute table", mimeType: "application/json" },
|
|
2931
|
+
async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(buildContract(), null, 2) }] })
|
|
2932
|
+
);
|
|
2111
2933
|
server.registerPrompt(
|
|
2112
2934
|
"design_app_icon",
|
|
2113
2935
|
{
|
|
2114
2936
|
title: "Design an app icon",
|
|
2115
2937
|
description: "Step-by-step workflow to design a Liquid Glass app icon from an app description",
|
|
2116
2938
|
argsSchema: {
|
|
2117
|
-
app:
|
|
2118
|
-
path:
|
|
2119
|
-
style:
|
|
2939
|
+
app: z4.string().describe("What the app does"),
|
|
2940
|
+
path: z4.string().optional().describe("Where to save the .icon bundle"),
|
|
2941
|
+
style: z4.string().optional().describe("Colors / mood / constraints")
|
|
2120
2942
|
}
|
|
2121
2943
|
},
|
|
2122
2944
|
({ app, path, style }) => ({
|
|
@@ -2145,12 +2967,6 @@ Use the fresnel tools:
|
|
|
2145
2967
|
);
|
|
2146
2968
|
return server;
|
|
2147
2969
|
}
|
|
2148
|
-
async function saveFile(ws, out, bytes) {
|
|
2149
|
-
const p = await ws.resolve(out);
|
|
2150
|
-
await mkdir2(dirname2(p), { recursive: true });
|
|
2151
|
-
await writeFile2(p, bytes);
|
|
2152
|
-
return p;
|
|
2153
|
-
}
|
|
2154
2970
|
|
|
2155
2971
|
// ../mcp/src/workspace.ts
|
|
2156
2972
|
import { realpath, stat as stat3 } from "fs/promises";
|
|
@@ -2158,8 +2974,11 @@ import { dirname as dirname3, isAbsolute as isAbsolute2, relative as relative2,
|
|
|
2158
2974
|
var Workspace = class {
|
|
2159
2975
|
roots;
|
|
2160
2976
|
locks = /* @__PURE__ */ new Map();
|
|
2161
|
-
|
|
2977
|
+
/** With `unrestricted` (the CLI without --root) any path is allowed; relative paths still start at the first root. */
|
|
2978
|
+
unrestricted;
|
|
2979
|
+
constructor(roots, opts = {}) {
|
|
2162
2980
|
this.roots = (roots.length ? roots : [process.cwd()]).map((r) => resolve2(r));
|
|
2981
|
+
this.unrestricted = !!opts.unrestricted;
|
|
2163
2982
|
}
|
|
2164
2983
|
/** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
|
|
2165
2984
|
async resolve(p, opts = {}) {
|
|
@@ -2185,6 +3004,7 @@ var Workspace = class {
|
|
|
2185
3004
|
}
|
|
2186
3005
|
}
|
|
2187
3006
|
async allowed(full) {
|
|
3007
|
+
if (this.unrestricted) return true;
|
|
2188
3008
|
for (const r of this.roots) {
|
|
2189
3009
|
let root = r;
|
|
2190
3010
|
try {
|
|
@@ -2209,12 +3029,12 @@ var Workspace = class {
|
|
|
2209
3029
|
return full;
|
|
2210
3030
|
}
|
|
2211
3031
|
/** Serializes edits per file so concurrent tool calls can't clobber each other. */
|
|
2212
|
-
async edit(path, fn) {
|
|
3032
|
+
async edit(path, fn, opts = {}) {
|
|
2213
3033
|
return this.withLock(path, async () => {
|
|
2214
3034
|
if (path.endsWith(".zip")) throw new Error("Editing .icon.zip is not supported; unzip it first or use the .icon folder");
|
|
2215
3035
|
const doc = await loadIcon(path);
|
|
2216
3036
|
const result = await fn(doc);
|
|
2217
|
-
await saveIcon(path, doc);
|
|
3037
|
+
if (!opts.dryRun) await saveIcon(path, doc);
|
|
2218
3038
|
return result;
|
|
2219
3039
|
});
|
|
2220
3040
|
}
|
|
@@ -2234,7 +3054,8 @@ var Workspace = class {
|
|
|
2234
3054
|
};
|
|
2235
3055
|
|
|
2236
3056
|
// src/cli.ts
|
|
2237
|
-
var
|
|
3057
|
+
var pad = (s, n2) => s.length >= n2 ? `${s} ` : s.padEnd(n2);
|
|
3058
|
+
var HELP = `fresnel ${VERSION} \u2014 Liquid Glass app icon tools (contract ${CONTRACT_VERSION})
|
|
2238
3059
|
|
|
2239
3060
|
Usage
|
|
2240
3061
|
fresnel build <spec.json> -o <Name.icon> Build an .icon bundle from a JSON spec
|
|
@@ -2245,14 +3066,42 @@ Usage
|
|
|
2245
3066
|
Render an approximate preview PNG
|
|
2246
3067
|
fresnel export <Name.icon> -o <dir> [--formats icon,icns,appiconset,png]
|
|
2247
3068
|
[--sizes 1024,512,...] [--appearances default,dark,...]
|
|
2248
|
-
[--tint #RRGGBB]
|
|
3069
|
+
[--tint #RRGGBB] [--force]
|
|
2249
3070
|
fresnel mcp [--root <dir>]... Start the MCP server on stdio
|
|
2250
3071
|
|
|
2251
3072
|
Render options: --mask squircle|circle|none, --tint #RRGGBB (tinted appearances)
|
|
2252
3073
|
|
|
3074
|
+
Tools (same arguments as the MCP server; JSON on stdout; "fresnel <command> --help" for details)
|
|
3075
|
+
${TOOLS.map((t) => ` ${pad(`fresnel ${cliCommand(t)}`, 31)}${t.summary.replace(/`/g, "")}`).join("\n")}
|
|
3076
|
+
|
|
3077
|
+
fresnel contract [--json] Print the SPEC contract (every tool, its schemas, roles and examples)
|
|
3078
|
+
Options for tools: --args '<json>' (all arguments at once), --root <dir> (limit file access)
|
|
3079
|
+
|
|
2253
3080
|
Appearances: ${APPEARANCES.join(", ")}
|
|
2254
3081
|
Docs: https://github.com/kajisho5/fresnel
|
|
2255
3082
|
`;
|
|
3083
|
+
async function callTool(t, argv) {
|
|
3084
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
3085
|
+
process.stdout.write(toolHelp(t));
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
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
|
+
const ws = new Workspace(roots, { unrestricted: roots.length === 0 });
|
|
3100
|
+
const { data, image } = await runTool(ws, t, parseToolArgv(t, rest));
|
|
3101
|
+
if (image && !data.saved) process.stderr.write("fresnel: the PNG was not saved; pass --out <file.png> to keep it\n");
|
|
3102
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
3103
|
+
`);
|
|
3104
|
+
}
|
|
2256
3105
|
async function build(specPath, out) {
|
|
2257
3106
|
const spec = JSON.parse(await readFile3(specPath, "utf8"));
|
|
2258
3107
|
const base = dirname4(resolve3(specPath));
|
|
@@ -2311,6 +3160,30 @@ async function main() {
|
|
|
2311
3160
|
console.log(VERSION);
|
|
2312
3161
|
return;
|
|
2313
3162
|
}
|
|
3163
|
+
if (cmd === "contract") {
|
|
3164
|
+
if (rest.some((a) => a !== "--json")) throw new Error("Usage: fresnel contract [--json]");
|
|
3165
|
+
process.stdout.write(`${JSON.stringify(buildContract(), null, 2)}
|
|
3166
|
+
`);
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
if (cmd === "call") {
|
|
3170
|
+
const [id, ...args] = rest;
|
|
3171
|
+
if (!id || id === "--help" || id === "-h") {
|
|
3172
|
+
process.stdout.write(`Usage: fresnel call <tool_id> [args]
|
|
3173
|
+
Tools: ${TOOLS.map((t2) => t2.id).join(", ")}
|
|
3174
|
+
`);
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
const t = toolById(id) ?? toolById(`icon_${id.replace(/-/g, "_")}`);
|
|
3178
|
+
if (!t) throw new Error(`Unknown tool "${id}". Tools: ${TOOLS.map((x) => x.id).join(", ")}`);
|
|
3179
|
+
await callTool(t, args);
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
const short = toolByCli(cmd);
|
|
3183
|
+
if (short && cliCommand(short) === short.cli) {
|
|
3184
|
+
await callTool(short, rest);
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
2314
3187
|
const { values, positionals } = parseArgs({
|
|
2315
3188
|
args: rest,
|
|
2316
3189
|
allowPositionals: true,
|
|
@@ -2327,6 +3200,7 @@ async function main() {
|
|
|
2327
3200
|
tint: { type: "string" },
|
|
2328
3201
|
mask: { type: "string" },
|
|
2329
3202
|
root: { type: "string", short: "r", multiple: true },
|
|
3203
|
+
force: { type: "boolean" },
|
|
2330
3204
|
help: { type: "boolean", short: "h" }
|
|
2331
3205
|
}
|
|
2332
3206
|
});
|
|
@@ -2381,14 +3255,19 @@ async function main() {
|
|
|
2381
3255
|
const formats = (values.formats ?? "icon,icns,appiconset,png").split(",").map((s) => s.trim());
|
|
2382
3256
|
const raster = nodeRasterizer(doc, values.tint ? { tint: values.tint } : {});
|
|
2383
3257
|
const outDir = resolve3(values.out);
|
|
3258
|
+
const missing = unrenderableLayers(doc);
|
|
3259
|
+
if (missing.length && formats.some((f) => f !== "icon") && !values.force)
|
|
3260
|
+
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
3261
|
let n2 = 0;
|
|
2385
3262
|
for (const f of formats) {
|
|
2386
|
-
if (f === "icon")
|
|
2387
|
-
|
|
3263
|
+
if (f === "icon") {
|
|
3264
|
+
await saveIcon(join3(outDir, `${name}.icon`), doc);
|
|
3265
|
+
n2 += 1 + Object.keys(doc.assets).length;
|
|
3266
|
+
} else if (f === "icns") {
|
|
2388
3267
|
await mkdir3(outDir, { recursive: true });
|
|
2389
3268
|
await writeFile3(join3(outDir, `${name}.icns`), await buildIcns(raster));
|
|
2390
3269
|
n2++;
|
|
2391
|
-
} else if (f === "appiconset") n2 += (await writeFiles(join3(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster))).length;
|
|
3270
|
+
} else if (f === "appiconset") n2 += (await writeFiles(join3(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) }))).length;
|
|
2392
3271
|
else if (f === "png") {
|
|
2393
3272
|
const sizes = (values.sizes ?? "1024,512,256,128,64,32,16").split(",").map(Number);
|
|
2394
3273
|
n2 += (await writeFiles(join3(outDir, `${name}-png`), await buildPngSet(raster, appearances(values.appearances), sizes))).length;
|