// Turns tree.fig.json into the compact payloads that get handed to use_figma. // // node scripts/figma-emit.mjs // // Writes /payload/-.json, one per section (split further when // a section would blow the 50k tool-argument budget), plus tables.json holding // the deduped fill / stroke / layout / text-style tables every payload indexes // into. Text is resolved here, not in Figma: each text node is mapped to a real // text style in the file (the Neue Haas ramp) or to Fragment Mono at its exact // size, and CSS text-transform is folded into the characters. import fs from "node:fs"; import path from "node:path"; const OUT = process.argv[2] || "figma-extract/w1648"; const src = JSON.parse(fs.readFileSync(path.join(OUT, "tree.fig.json"), "utf8")); // Optional: seed the tables from a previous run so their entries keep the same // indices. Lets an already-stored table in Figma stay valid — only the appended // delta has to be shipped. const SEED = process.argv[3] ? JSON.parse(fs.readFileSync(process.argv[3], "utf8")) : null; const PAY = path.join(OUT, "payload"); fs.rmSync(PAY, { recursive: true, force: true }); fs.mkdirSync(PAY, { recursive: true }); // ---- the file's real design system ----------------------------------------- // Neue Haas ramp (name -> px). Applied via setTextStyleIdAsync, which works even // though the family cannot be loaded here; the size therefore snaps to a rung. const RAMP = [ ["Jumbo", 96], ["Extra Large", 72], ["Large", 64], ["Headline Medium", 48], ["Subhead", 32], ["Body Large", 24], ["Body Small", 18], ["Detail", 16], ["Button-Link", 15], ]; const PAINTS = [ ["BS-Blue", [77, 30, 247]], ["BS-Black", [0, 0, 0]], ["BS-Dark grey", [34, 36, 37]], ["BS-Grey", [120, 126, 129]], ["BS-Light grey", [223, 223, 223]], ["BS-Off white", [244, 243, 243]], ["BS-White", [255, 255, 255]], ]; const R = (v) => Math.round(v); const R2 = (v) => Math.round(v * 100) / 100; class Table { constructor() { this.m = new Map(); } key(v) { if (v == null) return -1; const k = JSON.stringify(v); if (!this.m.has(k)) this.m.set(k, this.m.size); return this.m.get(k); } out() { return [...this.m.keys()].map((k) => JSON.parse(k)); } } const T = { fill: new Table(), stroke: new Table(), layout: new Table(), type: new Table(), color: new Table() }; const SEEDN = {}; if (SEED) { for (const k of Object.keys(T)) { SEEDN[k] = (SEED[k] || []).length; for (const v of SEED[k] || []) T[k].key(v); } } const snapStyle = (ft) => { if (/Fragment Mono/i.test(ft.family)) { // Real font, available — keep the exact metrics. return { m: 1, sz: R2(ft.size), lh: ft.lineHeight ? R2(ft.lineHeight) : null, ls: R2(ft.letterSpacing), w: ft.weight >= 500 ? "Regular" : "Regular", // Fragment Mono ships Regular only }; } let best = RAMP[0], d = Infinity; for (const [name, px] of RAMP) { const dist = Math.abs(px - ft.size); if (dist < d) { d = dist; best = [name, px]; } } return { m: 0, st: best[0], from: R2(ft.size), to: best[1] }; }; const colorKey = (c) => { const rgb = [c.r, c.g, c.b].map((v) => Math.round(v * 255)); const hit = PAINTS.find((p) => p[1].every((v, i) => Math.abs(v - rgb[i]) <= 1)); if (hit && (c.a == null || c.a > 0.995)) return { s: hit[0] }; return { c: rgb, a: c.a == null ? 1 : R2(c.a) }; }; const ALIGN = { left: "LEFT", right: "RIGHT", center: "CENTER", justify: "JUSTIFIED", start: "LEFT", end: "RIGHT" }; const snapReport = []; function conv(n) { const o = { t: n.t, w: R2(n.w), h: R2(n.h) }; if (n.n) o.n = n.n.slice(0, 40); if (n.t === "S") return { t: "S", w: R2(n.w), h: R2(n.h) }; if (n.t === "T") { const ft = n.ft; const sp = snapStyle(ft); if (!sp.m) snapReport.push([sp.from, sp.to, sp.st]); o.ty = T.type.key(sp); o.c = T.color.key(colorKey(ft.color)); if (ft.align && ALIGN[ft.align] && ALIGN[ft.align] !== "LEFT") o.al = ALIGN[ft.align]; let tx = n.tx || ""; if (ft.transform === "uppercase") tx = tx.toUpperCase(); else if (ft.transform === "lowercase") tx = tx.toLowerCase(); o.tx = tx; // Inline-styled ranges (a violet lead-in, a mono inline figure). Only the // ranges that differ from the node's own style are carried; the builder applies // them with setRangeTextStyleIdAsync / setRangeFills after the base style lands. // A text-transform that changes length would invalidate the offsets, so drop // the runs in that case rather than mis-colour the wrong characters. if (n.rn && tx.length === (n.tx || "").length) { const rn = []; for (const r of n.rn) { const rr = { s: r.s, e: Math.min(r.e, tx.length) }; if (rr.e <= rr.s) continue; const ty = T.type.key(snapStyle(r.ft)); const c = T.color.key(colorKey(r.ft.color)); if (ty !== o.ty) rr.ty = ty; if (c !== o.c) rr.c = c; if (rr.ty != null || rr.c != null) rn.push(rr); } if (rn.length) o.rn = rn; } // Single line -> hug; wrapped -> fixed width, auto height. const lh = ft.lineHeight || ft.size * 1.4; o.wrap = n.h > lh * 1.6 ? 1 : 0; return o; } if (n.t === "M") { o.img = n.img || null; if (n.r) o.r = n.r.map(R2); if (n.cs) o.cs = n.cs; o.n = ("img/" + (n.alt || n.src || n.k || "media")).replace(/\s+/g, " ").slice(0, 40); return o; } if (n.f) o.f = T.fill.key(n.f.map((f) => (f.kind === "SOLID" ? { k: "S", ...colorKey(f) } : f.kind === "GRADIENT" ? { k: "G", a: f.angle, s: f.stops.map((s) => ({ ...colorKey(s.color), p: R2(s.pos) })) } : { k: "I" }))); if (n.s) { const sides = n.s.map((s) => ({ w: R2(s.w), c: s.c ? colorKey(s.c) : null })); if (sides.some((s) => s.w > 0 && s.c)) o.s = T.stroke.key(sides); } if (n.r) o.r = n.r.map(R2); if (n.cs) o.cs = n.cs; if (n.o != null) o.o = n.o; if (n.cl) o.cl = 1; if (n.L) o.L = T.layout.key({ d: n.L.d, g: R2(n.L.g), p: n.L.p.map(R2), c: n.L.c }); if (n.ch) o.ch = n.ch.map(conv); if (n.abs) o.abs = n.abs.map((a) => ({ ...conv(a), ax: R2(a.ax), ay: R2(a.ay) })); return o; } const sections = src.sections.map((s) => { const c = conv(s); c.top = R2(s.top); c.left = R2(s.left); c.fixed = s.fixed; return c; }); const tables = { fill: T.fill.out(), stroke: T.stroke.out(), layout: T.layout.out(), type: T.type.out(), color: T.color.out(), }; fs.writeFileSync(path.join(PAY, "tables.json"), JSON.stringify(tables)); if (SEED) { const delta = {}; for (const k of Object.keys(tables)) delta[k] = tables[k].slice(SEEDN[k] || 0); fs.writeFileSync(path.join(PAY, "tables.delta.json"), JSON.stringify(delta)); } const slug = (s) => s.replace(/[^a-z0-9]+/gi, "-").toLowerCase().slice(0, 32); const MAXB = 34000; const files = []; sections.forEach((sec, i) => { const whole = JSON.stringify(sec); const nn = String(i).padStart(2, "0"); if (whole.length <= MAXB) { const f = nn + "-" + slug(sec.n) + ".json"; fs.writeFileSync(path.join(PAY, f), whole); files.push({ file: f, section: sec.n, kb: Math.round(whole.length / 1024), parts: 1 }); return; } // Too big: ship the shell, then each top-level child as its own payload. const kids = sec.ch || []; const shell = { ...sec, ch: [], _parts: kids.length }; const f0 = nn + "-" + slug(sec.n) + "-shell.json"; fs.writeFileSync(path.join(PAY, f0), JSON.stringify(shell)); files.push({ file: f0, section: sec.n, kb: Math.round(JSON.stringify(shell).length / 1024), parts: "shell" }); kids.forEach((k, j) => { const fj = nn + "-" + slug(sec.n) + "-p" + String(j).padStart(2, "0") + ".json"; fs.writeFileSync(path.join(PAY, fj), JSON.stringify(k)); files.push({ file: fj, section: sec.n, kb: Math.round(JSON.stringify(k).length / 1024), parts: "child " + j }); }); }); // Snap report: how far each Neue Haas node moved to land on the ramp. const agg = new Map(); for (const [from, to, st] of snapReport) { const k = from + "->" + to + " (" + st + ")"; agg.set(k, (agg.get(k) || 0) + 1); } const drift = [...agg.entries()].sort((a, b) => b[1] - a[1]); fs.writeFileSync(path.join(PAY, "index.json"), JSON.stringify({ files, tables: Object.fromEntries(Object.entries(tables).map(([k, v]) => [k, v.length])) }, null, 1)); console.log(JSON.stringify({ payloads: files.length, totalKB: Math.round(files.reduce((a, b) => a + b.kb, 0)), tablesKB: Math.round(JSON.stringify(tables).length / 1024), tableSizes: Object.fromEntries(Object.entries(tables).map(([k, v]) => [k, v.length])), exactSnaps: drift.filter(([k]) => k.split("->")[0] === k.split("->")[1].split(" ")[0]).reduce((a, b) => a + b[1], 0), drift: drift.slice(0, 18), }, null, 1));