fresnel-icon 0.1.1 → 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 +1086 -339
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -103,6 +103,9 @@ function normalizeColorInput(input) {
|
|
|
103
103
|
const n2 = (i) => parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
104
104
|
return formatColor({ space: "srgb", c: [n2(0), n2(2), n2(4), hex.length === 8 ? n2(6) : 1] });
|
|
105
105
|
}
|
|
106
|
+
function luminance({ r, g, b }) {
|
|
107
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
108
|
+
}
|
|
106
109
|
function mix(a, b, t) {
|
|
107
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 };
|
|
108
111
|
}
|
|
@@ -1309,6 +1312,107 @@ async function buildPngSet(rasterize, appearances, sizes) {
|
|
|
1309
1312
|
return files;
|
|
1310
1313
|
}
|
|
1311
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
|
+
|
|
1312
1416
|
// ../core/src/node.ts
|
|
1313
1417
|
import { lstat, mkdir, readdir, readFile, rm, stat, writeFile, rename } from "fs/promises";
|
|
1314
1418
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
@@ -1382,6 +1486,56 @@ async function writeFiles(dir, files) {
|
|
|
1382
1486
|
return written;
|
|
1383
1487
|
}
|
|
1384
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
|
+
|
|
1385
1539
|
// ../render/src/mask.ts
|
|
1386
1540
|
function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
|
|
1387
1541
|
const r = size / 2;
|
|
@@ -1407,6 +1561,57 @@ function maskPath(shape) {
|
|
|
1407
1561
|
return SQUIRCLE;
|
|
1408
1562
|
}
|
|
1409
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
|
+
|
|
1410
1615
|
// ../render/src/compose.ts
|
|
1411
1616
|
function rasterRenderOptions(ro) {
|
|
1412
1617
|
if (!ro) return {};
|
|
@@ -1419,7 +1624,7 @@ function rasterRenderOptions(ro) {
|
|
|
1419
1624
|
};
|
|
1420
1625
|
}
|
|
1421
1626
|
var C2 = CANVAS;
|
|
1422
|
-
var
|
|
1627
|
+
var H2 = C2 / 2;
|
|
1423
1628
|
var b64Cache = /* @__PURE__ */ new WeakMap();
|
|
1424
1629
|
var b64 = (bytes) => {
|
|
1425
1630
|
const hit = b64Cache.get(bytes);
|
|
@@ -1505,7 +1710,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1505
1710
|
const isClear = appearance === "clear-light" || appearance === "clear-dark";
|
|
1506
1711
|
const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
|
|
1507
1712
|
const tint = hexToRGBA(opts.tint ?? "#3FA7A0");
|
|
1508
|
-
const angle = (opts.lightAngle ??
|
|
1713
|
+
const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
|
|
1509
1714
|
const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
|
|
1510
1715
|
const defs = new Defs(opts.idPrefix ?? "");
|
|
1511
1716
|
const body = [];
|
|
@@ -1539,6 +1744,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1539
1744
|
bgPaint = null;
|
|
1540
1745
|
darken = 0;
|
|
1541
1746
|
}
|
|
1747
|
+
const backdropLuma = (fillLuma(isClear || isTinted ? dark ? "system-dark" : "system-light" : bgFill, dark) ?? 0.9) * (1 - darken);
|
|
1542
1748
|
const groups = FRONT_FIRST ? [...doc.groups].reverse() : [...doc.groups];
|
|
1543
1749
|
let stackId = defs.id("s");
|
|
1544
1750
|
const bgStackId = stackId;
|
|
@@ -1572,12 +1778,13 @@ function renderSvg(doc, opts = {}) {
|
|
|
1572
1778
|
const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
|
|
1573
1779
|
const w = asset.width * s;
|
|
1574
1780
|
const h = asset.height * s;
|
|
1575
|
-
placed.push({ layer: l, asset, x:
|
|
1781
|
+
placed.push({ layer: l, asset, x: H2 + tx - w / 2, y: H2 + ty - h / 2, w, h });
|
|
1576
1782
|
}
|
|
1577
1783
|
if (!placed.length) return null;
|
|
1578
1784
|
const imgId = /* @__PURE__ */ new Map();
|
|
1579
1785
|
const layerEls = [];
|
|
1580
1786
|
const glassEls = [];
|
|
1787
|
+
const glassLumas = [];
|
|
1581
1788
|
for (const p of placed) {
|
|
1582
1789
|
let iid = imgId.get(p.asset.name);
|
|
1583
1790
|
if (!iid) {
|
|
@@ -1592,7 +1799,9 @@ function renderSvg(doc, opts = {}) {
|
|
|
1592
1799
|
const fill = resolveProp(p.layer.props.fill, ctx, null);
|
|
1593
1800
|
const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
|
|
1594
1801
|
const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
|
|
1595
|
-
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;
|
|
1596
1805
|
let el;
|
|
1597
1806
|
if (paint) {
|
|
1598
1807
|
const mid = defs.id("lm");
|
|
@@ -1603,9 +1812,12 @@ function renderSvg(doc, opts = {}) {
|
|
|
1603
1812
|
}
|
|
1604
1813
|
const wrapped = `<g${opacity < 1 ? ` opacity="${n(opacity)}"` : ""}${blend ? ` style="mix-blend-mode:${blend}"` : ""}>${el}</g>`;
|
|
1605
1814
|
layerEls.push(wrapped);
|
|
1606
|
-
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
|
+
}
|
|
1607
1819
|
}
|
|
1608
|
-
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})"` : "";
|
|
1609
1821
|
const contentId = defs.id("gc");
|
|
1610
1822
|
defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
|
|
1611
1823
|
contentIds.push(contentId);
|
|
@@ -1638,7 +1850,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1638
1850
|
defs.add(`<filter id="${fid}" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
|
|
1639
1851
|
const zoom = 1.02 + (refracts ? (refr.strength ?? 0) * 0.12 : 0.015);
|
|
1640
1852
|
out.push(
|
|
1641
|
-
`<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>`
|
|
1642
1854
|
);
|
|
1643
1855
|
}
|
|
1644
1856
|
const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
|
|
@@ -1656,7 +1868,9 @@ function renderSvg(doc, opts = {}) {
|
|
|
1656
1868
|
out.push(
|
|
1657
1869
|
`<g${fadeMask}${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}><use href="#${contentId}"/></g>`
|
|
1658
1870
|
);
|
|
1659
|
-
const
|
|
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;
|
|
1660
1874
|
if (effects && specular && glassEls.length) {
|
|
1661
1875
|
const k = 7;
|
|
1662
1876
|
const dx = n(-toLight.x * k);
|
|
@@ -1712,8 +1926,8 @@ function renderSvg(doc, opts = {}) {
|
|
|
1712
1926
|
} else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
|
|
1713
1927
|
if (effects && opts.rim !== false && mask !== "none") {
|
|
1714
1928
|
const rid = defs.id("rim");
|
|
1715
|
-
const lx =
|
|
1716
|
-
const ly =
|
|
1929
|
+
const lx = H2 + toLight.x * H2;
|
|
1930
|
+
const ly = H2 + toLight.y * H2;
|
|
1717
1931
|
defs.add(
|
|
1718
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>`
|
|
1719
1933
|
);
|
|
@@ -1732,11 +1946,11 @@ function cssBlend(mode) {
|
|
|
1732
1946
|
var SHEET_ORDER = ["default", "clear-light", "tinted-light", "dark", "clear-dark", "tinted-dark"];
|
|
1733
1947
|
function renderSheetSvg(doc, opts = {}) {
|
|
1734
1948
|
const cell = opts.cell ?? 320;
|
|
1735
|
-
const
|
|
1949
|
+
const pad2 = Math.round(cell * 0.18);
|
|
1736
1950
|
const label = opts.labels !== false ? Math.round(cell * 0.12) : 0;
|
|
1737
1951
|
const w = cell * 3;
|
|
1738
1952
|
const h = (cell + label) * 2;
|
|
1739
|
-
const icon = cell -
|
|
1953
|
+
const icon = cell - pad2 * 2;
|
|
1740
1954
|
const parts = [];
|
|
1741
1955
|
SHEET_ORDER.forEach((ap, i) => {
|
|
1742
1956
|
const x = i % 3 * cell;
|
|
@@ -1744,7 +1958,7 @@ function renderSheetSvg(doc, opts = {}) {
|
|
|
1744
1958
|
const darkBg = isDarkAppearance(ap);
|
|
1745
1959
|
parts.push(`<rect x="${x}" y="${y}" width="${cell}" height="${cell + label}" fill="${darkBg ? "#14161F" : "#E3E8F0"}"/>`);
|
|
1746
1960
|
const svg = renderSvg(doc, { ...opts, appearance: ap, size: icon, idPrefix: `a${i}_` });
|
|
1747
|
-
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}"`));
|
|
1748
1962
|
if (label)
|
|
1749
1963
|
parts.push(
|
|
1750
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>`
|
|
@@ -1769,11 +1983,28 @@ async function svgToPng(svg, width) {
|
|
|
1769
1983
|
const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
|
|
1770
1984
|
return new Uint8Array(r.render().asPng());
|
|
1771
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
|
+
}
|
|
1772
2001
|
async function renderPng(doc, opts = {}) {
|
|
2002
|
+
await ensureContentBoxes(doc);
|
|
1773
2003
|
const size = opts.size ?? 1024;
|
|
1774
2004
|
return svgToPng(renderSvg(doc, { ...opts, size }), size);
|
|
1775
2005
|
}
|
|
1776
2006
|
async function renderSheetPng(doc, opts = {}) {
|
|
2007
|
+
await ensureContentBoxes(doc);
|
|
1777
2008
|
const cell = opts.cell ?? 320;
|
|
1778
2009
|
return svgToPng(renderSheetSvg(doc, opts), cell * 3);
|
|
1779
2010
|
}
|
|
@@ -1781,98 +2012,180 @@ function nodeRasterizer(doc, opts = {}) {
|
|
|
1781
2012
|
return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...rasterRenderOptions(ro) });
|
|
1782
2013
|
}
|
|
1783
2014
|
|
|
1784
|
-
// ../mcp/src/
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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")}
|
|
1837
2089
|
`;
|
|
1838
2090
|
}
|
|
1839
|
-
var
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
+
}
|
|
1848
2171
|
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
- Fill: "automatic" | "system-light" | "system-dark" | "none" | {"solid": color} | {"automatic-gradient": color} | {"linear-gradient": [color, color], "orientation": {"start": {"x","y"}, "stop": {"x","y"}}}
|
|
1852
|
-
Shorthands: "#336699" \u2192 solid, "gradient:#top,#bottom" \u2192 linear-gradient, "auto-gradient:#color" \u2192 automatic-gradient
|
|
1853
|
-
- Position: {"scale": 1, "translation-in-points": [x, y]} (points on the 1024 canvas, y down, relative to center)
|
|
1854
|
-
- Shadow: {"kind": "none" | "neutral" | "layer-color", "opacity": 0\u20131}
|
|
1855
|
-
- Translucency: {"enabled": true, "value": 0\u20131}
|
|
1856
|
-
- 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
|
|
1857
|
-
`;
|
|
2172
|
+
// ../mcp/src/contract.ts
|
|
2173
|
+
import { z as z3 } from "zod";
|
|
1858
2174
|
|
|
1859
|
-
// ../mcp/src/
|
|
1860
|
-
|
|
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;
|
|
1861
2181
|
var appearanceEnum = z2.enum(APPEARANCES);
|
|
1862
2182
|
var overrideEnum = z2.enum(["dark", "tinted"]);
|
|
2183
|
+
var platformEnum = z2.enum(["iOS", "macOS", "watchOS", "square"]);
|
|
1863
2184
|
var maskEnum = z2.enum(["squircle", "circle", "none"]);
|
|
1864
|
-
var
|
|
1865
|
-
var
|
|
1866
|
-
var
|
|
1867
|
-
|
|
1868
|
-
return async (args) => {
|
|
1869
|
-
try {
|
|
1870
|
-
return await fn(args);
|
|
1871
|
-
} catch (e) {
|
|
1872
|
-
return fail(e);
|
|
1873
|
-
}
|
|
1874
|
-
};
|
|
1875
|
-
}
|
|
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()) }));
|
|
1876
2189
|
function describeProps(props) {
|
|
1877
2190
|
const base = {};
|
|
1878
2191
|
const overrides = {};
|
|
@@ -1904,6 +2217,7 @@ function inspect(doc) {
|
|
|
1904
2217
|
assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
|
|
1905
2218
|
};
|
|
1906
2219
|
}
|
|
2220
|
+
var outline = (doc) => inspect(doc).groups.map((g) => ({ group: g.target, layers: g.layers.map((l) => l.target) }));
|
|
1907
2221
|
function targetLabel(doc, ref) {
|
|
1908
2222
|
if (ref.kind === "document") return "document";
|
|
1909
2223
|
if (ref.kind === "group") return nameOf(findGroup(doc, ref.id));
|
|
@@ -1913,29 +2227,41 @@ function targetLabel(doc, ref) {
|
|
|
1913
2227
|
}
|
|
1914
2228
|
return "?";
|
|
1915
2229
|
}
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
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
|
|
1937
2256
|
},
|
|
1938
|
-
|
|
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 }) {
|
|
1939
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)");
|
|
1940
2266
|
const full = await ws.iconPath(path);
|
|
1941
2267
|
const exists = await stat2(full).then(() => true, () => false);
|
|
@@ -1945,63 +2271,101 @@ function createServer(ws) {
|
|
|
1945
2271
|
platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
|
|
1946
2272
|
});
|
|
1947
2273
|
if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
|
|
1948
|
-
await ws.withLock(full, () => saveIcon(full, doc));
|
|
1949
|
-
return
|
|
1950
|
-
}
|
|
1951
|
-
)
|
|
1952
|
-
|
|
1953
|
-
"icon_inspect",
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
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 }) {
|
|
1961
2296
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
1962
|
-
return
|
|
1963
|
-
}
|
|
1964
|
-
)
|
|
1965
|
-
|
|
1966
|
-
"icon_add_group",
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
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
|
|
1976
2315
|
},
|
|
1977
|
-
|
|
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 }) {
|
|
1978
2324
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
1979
|
-
const
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
|
2003
2357
|
},
|
|
2004
|
-
|
|
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 }) {
|
|
2005
2369
|
const given = [svg, source, png_base64].filter((x) => x !== void 0).length;
|
|
2006
2370
|
if (given !== 1) throw new Error("Provide exactly one of svg, source or png_base64");
|
|
2007
2371
|
let bytes;
|
|
@@ -2020,129 +2384,242 @@ function createServer(ws) {
|
|
|
2020
2384
|
const asset = makeAsset(fname, bytes);
|
|
2021
2385
|
if (asset.kind === "other") throw new Error("Unsupported image. Use SVG, PNG or JPEG.");
|
|
2022
2386
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
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
|
|
2060
2432
|
},
|
|
2061
|
-
|
|
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 }) {
|
|
2062
2444
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2063
|
-
const
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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 }) {
|
|
2081
2476
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2082
|
-
const
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
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
|
|
2097
2511
|
},
|
|
2098
|
-
|
|
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 }) {
|
|
2099
2520
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2100
|
-
const
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
if (
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
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 }) {
|
|
2123
2591
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2124
2592
|
const issues = validate(await ws.read(full)).map(({ ref: _ref, ...i }) => i);
|
|
2125
|
-
return
|
|
2126
|
-
}
|
|
2127
|
-
)
|
|
2128
|
-
|
|
2129
|
-
"icon_render",
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
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")
|
|
2144
2614
|
},
|
|
2145
|
-
|
|
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 }) {
|
|
2146
2623
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2147
2624
|
const doc = await ws.read(full);
|
|
2148
2625
|
const png = await renderPng(doc, {
|
|
@@ -2154,43 +2631,62 @@ function createServer(ws) {
|
|
|
2154
2631
|
...light_angle !== void 0 ? { lightAngle: light_angle } : {}
|
|
2155
2632
|
});
|
|
2156
2633
|
const saved = out ? await saveFile(ws, out, png) : void 0;
|
|
2157
|
-
return
|
|
2158
|
-
}
|
|
2159
|
-
)
|
|
2160
|
-
|
|
2161
|
-
"icon_render_sheet",
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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()
|
|
2172
2651
|
},
|
|
2173
|
-
|
|
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 }) {
|
|
2174
2660
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2175
2661
|
const png = await renderSheetPng(await ws.read(full), { cell: cell ?? 240, ...tint ? { tint } : {} });
|
|
2176
2662
|
const saved = out ? await saveFile(ws, out, png) : void 0;
|
|
2177
|
-
return
|
|
2178
|
-
}
|
|
2179
|
-
)
|
|
2180
|
-
|
|
2181
|
-
"icon_export",
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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")
|
|
2192
2681
|
},
|
|
2193
|
-
|
|
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 }) {
|
|
2194
2690
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2195
2691
|
const outDir = await ws.resolve(out_dir);
|
|
2196
2692
|
const doc = await ws.read(full);
|
|
@@ -2214,14 +2710,208 @@ function createServer(ws) {
|
|
|
2214
2710
|
if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
|
|
2215
2711
|
if (formats.includes("png"))
|
|
2216
2712
|
written.push(
|
|
2217
|
-
...await writeFiles(
|
|
2218
|
-
join2(outDir, `${name}-png`),
|
|
2219
|
-
await buildPngSet(raster, appearances ?? [...APPEARANCES], sizes ?? [1024, 512, 256, 128, 64, 32, 16])
|
|
2220
|
-
)
|
|
2713
|
+
...await writeFiles(join2(outDir, `${name}-png`), await buildPngSet(raster, appearances ?? [...APPEARANCES], sizes ?? [1024, 512, 256, 128, 64, 32, 16]))
|
|
2221
2714
|
);
|
|
2222
|
-
return
|
|
2223
|
-
}
|
|
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 }])
|
|
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
|
+
}
|
|
2224
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
|
+
}
|
|
2225
2915
|
server.registerResource(
|
|
2226
2916
|
"icon-design-guide",
|
|
2227
2917
|
"fresnel://guide/icon-design",
|
|
@@ -2234,15 +2924,21 @@ function createServer(ws) {
|
|
|
2234
2924
|
{ title: "icon.json attribute reference", description: "Keys, value formats and defaults fresnel understands", mimeType: "text/markdown" },
|
|
2235
2925
|
async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: SCHEMA_GUIDE }] })
|
|
2236
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
|
+
);
|
|
2237
2933
|
server.registerPrompt(
|
|
2238
2934
|
"design_app_icon",
|
|
2239
2935
|
{
|
|
2240
2936
|
title: "Design an app icon",
|
|
2241
2937
|
description: "Step-by-step workflow to design a Liquid Glass app icon from an app description",
|
|
2242
2938
|
argsSchema: {
|
|
2243
|
-
app:
|
|
2244
|
-
path:
|
|
2245
|
-
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")
|
|
2246
2942
|
}
|
|
2247
2943
|
},
|
|
2248
2944
|
({ app, path, style }) => ({
|
|
@@ -2271,12 +2967,6 @@ Use the fresnel tools:
|
|
|
2271
2967
|
);
|
|
2272
2968
|
return server;
|
|
2273
2969
|
}
|
|
2274
|
-
async function saveFile(ws, out, bytes) {
|
|
2275
|
-
const p = await ws.resolve(out);
|
|
2276
|
-
await mkdir2(dirname2(p), { recursive: true });
|
|
2277
|
-
await writeFile2(p, bytes);
|
|
2278
|
-
return p;
|
|
2279
|
-
}
|
|
2280
2970
|
|
|
2281
2971
|
// ../mcp/src/workspace.ts
|
|
2282
2972
|
import { realpath, stat as stat3 } from "fs/promises";
|
|
@@ -2284,8 +2974,11 @@ import { dirname as dirname3, isAbsolute as isAbsolute2, relative as relative2,
|
|
|
2284
2974
|
var Workspace = class {
|
|
2285
2975
|
roots;
|
|
2286
2976
|
locks = /* @__PURE__ */ new Map();
|
|
2287
|
-
|
|
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 = {}) {
|
|
2288
2980
|
this.roots = (roots.length ? roots : [process.cwd()]).map((r) => resolve2(r));
|
|
2981
|
+
this.unrestricted = !!opts.unrestricted;
|
|
2289
2982
|
}
|
|
2290
2983
|
/** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
|
|
2291
2984
|
async resolve(p, opts = {}) {
|
|
@@ -2311,6 +3004,7 @@ var Workspace = class {
|
|
|
2311
3004
|
}
|
|
2312
3005
|
}
|
|
2313
3006
|
async allowed(full) {
|
|
3007
|
+
if (this.unrestricted) return true;
|
|
2314
3008
|
for (const r of this.roots) {
|
|
2315
3009
|
let root = r;
|
|
2316
3010
|
try {
|
|
@@ -2335,12 +3029,12 @@ var Workspace = class {
|
|
|
2335
3029
|
return full;
|
|
2336
3030
|
}
|
|
2337
3031
|
/** Serializes edits per file so concurrent tool calls can't clobber each other. */
|
|
2338
|
-
async edit(path, fn) {
|
|
3032
|
+
async edit(path, fn, opts = {}) {
|
|
2339
3033
|
return this.withLock(path, async () => {
|
|
2340
3034
|
if (path.endsWith(".zip")) throw new Error("Editing .icon.zip is not supported; unzip it first or use the .icon folder");
|
|
2341
3035
|
const doc = await loadIcon(path);
|
|
2342
3036
|
const result = await fn(doc);
|
|
2343
|
-
await saveIcon(path, doc);
|
|
3037
|
+
if (!opts.dryRun) await saveIcon(path, doc);
|
|
2344
3038
|
return result;
|
|
2345
3039
|
});
|
|
2346
3040
|
}
|
|
@@ -2360,7 +3054,8 @@ var Workspace = class {
|
|
|
2360
3054
|
};
|
|
2361
3055
|
|
|
2362
3056
|
// src/cli.ts
|
|
2363
|
-
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})
|
|
2364
3059
|
|
|
2365
3060
|
Usage
|
|
2366
3061
|
fresnel build <spec.json> -o <Name.icon> Build an .icon bundle from a JSON spec
|
|
@@ -2376,9 +3071,37 @@ Usage
|
|
|
2376
3071
|
|
|
2377
3072
|
Render options: --mask squircle|circle|none, --tint #RRGGBB (tinted appearances)
|
|
2378
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
|
+
|
|
2379
3080
|
Appearances: ${APPEARANCES.join(", ")}
|
|
2380
3081
|
Docs: https://github.com/kajisho5/fresnel
|
|
2381
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
|
+
}
|
|
2382
3105
|
async function build(specPath, out) {
|
|
2383
3106
|
const spec = JSON.parse(await readFile3(specPath, "utf8"));
|
|
2384
3107
|
const base = dirname4(resolve3(specPath));
|
|
@@ -2437,6 +3160,30 @@ async function main() {
|
|
|
2437
3160
|
console.log(VERSION);
|
|
2438
3161
|
return;
|
|
2439
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
|
+
}
|
|
2440
3187
|
const { values, positionals } = parseArgs({
|
|
2441
3188
|
args: rest,
|
|
2442
3189
|
allowPositionals: true,
|