// Extracts a Figma-ready layout tree from a rendered page. // // node scripts/figma-extract.mjs // // Emits /tree.raw.json (the layout tree) and /media/.png // (a real capture of every img / video / canvas / svg, so the Figma port shows // the actual artwork rather than grey boxes). Captures are PNG on purpose — // WebP uploads to Figma silently render black. import { chromium } from "playwright"; import fs from "node:fs"; import path from "node:path"; const URL_ = process.argv[2] || "http://localhost:3000/en"; const WIDTH = Number(process.argv[3] || 1440); const OUT = process.argv[4] || "figma-extract/desktop"; fs.mkdirSync(path.join(OUT, "media"), { recursive: true }); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: WIDTH, height: 900 }, deviceScaleFactor: 2, }); await page.goto(URL_, { waitUntil: "networkidle", timeout: 120000 }); // Scroll-driven reveals only exist at some scroll offset. Capturing once at the top // therefore misses every staggered reveal and, worse, everything inside a pinned // track: a 440vh section pinned to a 100vh stage is recorded in its opening state, // so its revealed content sits at opacity:0 and gets dropped by the visibility // filter. Sweep the page, record the most-revealed state each hidden element ever // reaches, then replay that as forced CSS before the walk. Layout is unaffected — a // pinned stage lays its content out identically at every offset, only opacity / // transform / visibility change — so geometry captured at the top stays correct. const reveal = await page.evaluate(async () => { const settle = (ms) => new Promise((r) => requestAnimationFrame(() => setTimeout(r, ms))); // Only something hidden at rest can be "revealed" later — track just those. const cand = []; document.querySelectorAll("*").forEach((el, i) => { const cs = getComputedStyle(el); if (cs.display === "none") return; const op = parseFloat(cs.opacity); if (op < 0.99 || cs.visibility === "hidden") { el.setAttribute("data-fxr", "r" + i); cand.push({ el, id: "r" + i, rest: Number.isFinite(op) ? op : 1, best: -1, tf: "none" }); } }); const sample = () => { for (const c of cand) { const cs = getComputedStyle(c.el); if (cs.display === "none") continue; const op = parseFloat(cs.opacity); if (!Number.isFinite(op) || op <= c.best) continue; c.best = op; c.tf = cs.transform || "none"; } }; // Half-viewport steps, so a long pinned track gets sampled several times inside // its own scroll range rather than jumped over. const step = window.innerHeight * 0.5; const end = document.body.scrollHeight; sample(); for (let y = 0; y < end; y += step) { window.scrollTo(0, y); await settle(110); sample(); } window.scrollTo(0, end); await settle(160); sample(); window.scrollTo(0, 0); await settle(500); // A rule only where scrolling actually revealed something. Elements that stay // hidden throughout (closed nav flyouts) are left alone. const rules = []; for (const c of cand) { if (c.best <= Math.max(c.rest, 0.02) + 0.01) continue; const bits = ["opacity:" + c.best + "!important", "visibility:visible!important"]; bits.push("transform:" + (c.tf && c.tf !== "none" ? c.tf : "none") + "!important"); rules.push('[data-fxr="' + c.id + '"]{' + bits.join(";") + "}"); } return { css: rules.join("\n"), candidates: cand.length, revealed: rules.length }; }); await page.addStyleTag({ content: '.rv,[data-rv],[data-reveal],[class*="reveal"]{opacity:1!important;transform:none!important;visibility:visible!important;filter:none!important;clip-path:none!important}', }); if (reveal.css) await page.addStyleTag({ content: reveal.css }); // Extra CSS (e.g. force nav flyouts open so their panels can be captured). if (process.env.EXTRA_CSS) await page.addStyleTag({ content: process.env.EXTRA_CSS }); await page.waitForTimeout(1500); const tree = await page.evaluate(() => { const num = (v) => parseFloat(v) || 0; const MEDIA = new Set(["IMG", "VIDEO", "CANVAS", "SVG", "IFRAME", "PICTURE"]); const SKIP = new Set([ "SCRIPT", "STYLE", "NOSCRIPT", "LINK", "META", "TEMPLATE", "HEAD", "TITLE", "SOURCE", "TRACK", ]); let uid = 0; // Split a background/gradient list on its top-level commas only. const layers = (s) => { const out = []; let depth = 0, cur = ""; for (const ch of s || "") { if (ch === "(") depth++; else if (ch === ")") depth--; if (ch === "," && depth === 0) { out.push(cur); cur = ""; continue; } cur += ch; } if (cur.trim()) out.push(cur); return out; }; const color = (s) => { if (!s || s === "transparent" || s === "none") return null; // color-mix() and friends compute to color(srgb r g b / a) in Chrome, which // the rgb() regex below never matched — every color-mix hairline on the page // was being dropped silently. const cm = s.match(/color\(srgb\s+([^)]+)\)/); if (cm) { const p = cm[1].split(/[\s/]+/).filter(Boolean).map(Number); const a = p.length > 3 ? p[3] : 1; if (!a) return null; return { r: p[0], g: p[1], b: p[2], a }; } const m = s.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number); const a = p.length > 3 ? p[3] : 1; if (!a) return null; return { r: p[0] / 255, g: p[1] / 255, b: p[2] / 255, a }; }; const gradient = (bg) => { if (!bg || bg === "none") return null; // Several stacked gradient layers is the standard way to draw thin rules // (paired with background-size). Read greedily as one gradient it becomes a // full-bleed slab — that is what buried the eyetracking readout in white. const ls = layers(bg); if (ls.length > 1) return null; const m = ls[0].match(/linear-gradient\(([\s\S]*)\)\s*$/); if (!m) return null; const parts = layers(m[1]); const dirs = { "to top": 0, "to right": 90, "to bottom": 180, "to left": 270, "to top right": 45, "to right top": 45, "to bottom right": 135, "to right bottom": 135, "to bottom left": 225, "to left bottom": 225, "to top left": 315, "to left top": 315, }; let angle = 180; let idx = 0; const head = parts[0].trim(); if (/^to\s/.test(head)) { angle = dirs[head] ?? 180; idx = 1; } else if (/deg\s*$/.test(head)) { angle = num(head); idx = 1; } const raw = parts.slice(idx); const stops = []; raw.forEach((p, i) => { const t = p.trim(); const cm = t.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8})/); const pm = t.match(/(-?[\d.]+)%/); const c = color(cm ? cm[1] : t); if (!c) return; stops.push({ color: c, pos: pm ? num(pm[1]) / 100 : i / Math.max(1, raw.length - 1) }); }); return stops.length >= 2 ? { angle, stops } : null; }; const bgImageUrl = (bg) => { const m = bg && bg.match(/url\(["']?([^"')]+)["']?\)/); return m ? m[1] : null; }; const shadows = (s) => { if (!s || s === "none") return []; const out = []; for (const part of s.split(/,(?![^(]*\))/)) { const c = color(part); const nums = part.match(/(-?[\d.]+)px/g) || []; if (!c || nums.length < 2) continue; out.push({ inset: /inset/.test(part), x: num(nums[0]), y: num(nums[1]), blur: num(nums[2] || "0"), spread: num(nums[3] || "0"), color: c, }); } return out; }; const pseudoText = (el, which) => { const c = getComputedStyle(el, which).content; if (!c || c === "none" || c === "normal") return ""; return /^["']/.test(c) ? c.slice(1, -1) : ""; }; const pseudoOverlay = (el, which, rect) => { const cs = getComputedStyle(el, which); if (cs.content === "none" || cs.display === "none") return null; // The slash-mono kicker ("/ EXPERIENCES") is a block ::before carrying text. // It only used to survive on the text path, so it vanished whenever its host // resolved to a frame. Emit it as a real text node instead, positioned at the // host's content edge; the normaliser picks up its margin as the gap. const txt = /^["']/.test(cs.content) ? cs.content.slice(1, -1) : ""; if (txt && cs.position === "static" && !/^inline$|^contents$/.test(cs.display)) { const pcs = getComputedStyle(el); const h = num(cs.height) || num(cs.fontSize) * 1.4; return { id: "p" + uid++, type: "TEXT", tag: "pseudo", name: (which === "::before" ? "before/" : "after/") + txt.slice(0, 24), x: rect.left + scrollX + num(pcs.borderLeftWidth) + num(pcs.paddingLeft), y: which === "::before" ? rect.top + scrollY + num(pcs.borderTopWidth) + num(pcs.paddingTop) : rect.bottom + scrollY - num(pcs.borderBottomWidth) - num(pcs.paddingBottom) - h, w: num(cs.width) || rect.width, h, text: txt, font: fontOf(cs), fills: [], strokes: [], radius: [0, 0, 0, 0], opacity: num(cs.opacity) || 1, // Paint order: an absolutely-positioned sibling with a higher z-index sits // on top regardless of DOM order. Without this the experiences video // buried its own headline. z: cs.zIndex === "auto" ? 0 : num(cs.zIndex), clip: false, shadows: [], pos: "static", }; } const g = gradient(cs.backgroundImage); const bg = color(cs.backgroundColor); if (!g && !bg) return null; const fill = g ? { kind: "GRADIENT", ...g } : { kind: "SOLID", ...bg }; return { id: "p" + uid++, type: "FRAME", tag: "pseudo", name: which === "::before" ? "overlay/before" : "overlay/after", x: rect.left + scrollX, y: rect.top + scrollY, w: rect.width, h: rect.height, fills: [fill], strokes: [], radius: [0, 0, 0, 0], opacity: num(cs.opacity) || 1, children: [], clip: false, shadows: [], display: "block", flexDir: "row", gap: [0, 0], padding: [0, 0, 0, 0], pos: "absolute", }; }; const isVisible = (el, cs, rect) => { if (cs.display === "none" || cs.visibility === "hidden") return false; if (num(cs.opacity) === 0) return false; if (rect.width < 0.5 || rect.height < 0.5) return false; if (cs.clip === "rect(0px, 0px, 0px, 0px)") return false; // .vh screen-reader text return true; }; // Text is collected as [char, owningElement] pairs rather than a flat string, so // a headline like "Beyond 2 introduces ..." keeps the violet // on its first two words. Whitespace normalisation runs over the pairs, so run // offsets always index the final string. const segsOf = (el) => { const segs = []; const rec = (n, owner) => { for (const c of n.childNodes) { if (c.nodeType === 3) { if (c.nodeValue) segs.push([c.nodeValue, owner]); } else if (c.nodeType === 1) { if (c.tagName === "BR") segs.push(["\n", owner]); else if (!SKIP.has(c.tagName)) rec(c, c); } } }; rec(el, el); return segs; }; const WS = /[ \t\r\f\v]/; const charsOf = (el) => { const raw = []; for (const [t, owner] of segsOf(el)) for (const ch of t) raw.push([ch, owner]); const a = []; for (let i = 0; i < raw.length; i++) { if (WS.test(raw[i][0])) { a.push([" ", raw[i][1]]); while (i + 1 < raw.length && WS.test(raw[i + 1][0])) i++; } else a.push(raw[i]); } const b = []; for (let i = 0; i < a.length; i++) { b.push(a[i]); if (a[i][0] === "\n" && i + 1 < a.length && a[i + 1][0] === " ") i++; } let s = 0, e = b.length; while (s < e && /\s/.test(b[s][0])) s++; while (e > s && /\s/.test(b[e - 1][0])) e--; return b.slice(s, e); }; const textOf = (el) => charsOf(el).map((c) => c[0]).join(""); // Adjacent characters sharing a computed font/colour collapse into one run. const runsOf = (chars) => { const spans = []; for (let i = 0; i < chars.length; i++) { const owner = chars[i][1]; const last = spans[spans.length - 1]; if (last && last.owner === owner) { last.e = i + 1; continue; } spans.push({ owner, s: i, e: i + 1 }); } const merged = []; for (const r of spans) { const ft = fontOf(getComputedStyle(r.owner)); const sig = JSON.stringify(ft); const last = merged[merged.length - 1]; if (last && last.sig === sig) { last.e = r.e; continue; } merged.push({ s: r.s, e: r.e, ft, sig }); } return merged.map(({ s, e, ft }) => ({ s, e, ft })); }; // Checking only the direct children was not enough: an inline wrapping // block

s (Builder's standard text block) looked like a leaf, so a kicker, // headline and body collapsed into one concatenated string. The whole subtree // has to be inline for the element to be a single text node. const isTextLeaf = (el) => { if (!textOf(el)) return false; const blocky = (n) => { for (const c of n.children) { if (SKIP.has(c.tagName)) continue; if (MEDIA.has(c.tagName)) return true; const cs = getComputedStyle(c); if (cs.display === "none") continue; // Builder leaves empty

markers behind; an empty block carries no // content and must not break a flowing paragraph into separate nodes. // But an empty block that *paints* is real content — the 12px swatch in // the scene toggle is a bordered span with no text, and skipping it // silently swallowed the buttons' icons. const boxPaints = (cs.backgroundColor && cs.backgroundColor !== "rgba(0, 0, 0, 0)" && cs.backgroundColor !== "transparent") || cs.backgroundImage !== "none" || ["Top", "Right", "Bottom", "Left"].some((s) => num(cs["border" + s + "Width"]) > 0); if (!/^inline/.test(cs.display) && cs.display !== "contents" && (textOf(c) || boxPaints)) return true; if (blocky(c)) return true; } return false; }; return !blocky(el); }; const nameFor = (el) => { if (el.id) return "#" + el.id; const cls = (typeof el.className === "string" ? el.className : "") .trim().split(/\s+/) .filter((c) => c && !/^css-/.test(c) && !/^builder-/.test(c))[0]; return cls ? el.tagName.toLowerCase() + "." + cls : el.tagName.toLowerCase(); }; const fontOf = (cs) => ({ family: cs.fontFamily.split(",")[0].replace(/["']/g, "").trim(), size: num(cs.fontSize), weight: num(cs.fontWeight) || 400, italic: cs.fontStyle === "italic", lineHeight: cs.lineHeight === "normal" ? null : num(cs.lineHeight), letterSpacing: cs.letterSpacing === "normal" ? 0 : num(cs.letterSpacing), align: cs.textAlign, transform: cs.textTransform, decoration: /underline/.test(cs.textDecorationLine) ? "UNDERLINE" : "NONE", color: color(cs.color) || { r: 1, g: 1, b: 1, a: 1 }, }); // corner-shape drives the brand's angular system (45 of 48 uses on the home // page are "round round bevel round"). Figma has no equivalent, so the builder // turns a bevelled box into a real vector path — but only if we record which // corners are cut. Expands with the same 1-4 value rules as border-radius. const cornerShape = (v) => { const p = (v || "").trim().split(/\s+/).filter(Boolean); if (!p.length) return null; const [a, b = a, c = a, d = b] = p; return [a, b, c, d]; }; const boxOf = (el, cs) => { const fills = []; const bg = color(cs.backgroundColor); if (bg) fills.push({ kind: "SOLID", ...bg }); const g = gradient(cs.backgroundImage); if (g) fills.push({ kind: "GRADIENT", ...g }); const bgu = bgImageUrl(cs.backgroundImage); if (bgu) fills.push({ kind: "IMAGE", url: bgu, fit: cs.backgroundSize }); const sides = ["Top", "Right", "Bottom", "Left"].map((s) => ({ w: num(cs["border" + s + "Width"]), c: color(cs["border" + s + "Color"]), style: cs["border" + s + "Style"], })); const strokes = sides.some((s) => s.w > 0 && s.c && s.style !== "none") ? sides : []; const cshape = cornerShape(cs.getPropertyValue("corner-shape")); return { fills, strokes, radius: [ cs.borderTopLeftRadius, cs.borderTopRightRadius, cs.borderBottomRightRadius, cs.borderBottomLeftRadius, ].map(num), cshape: cshape && cshape.some((s) => s !== "round") ? cshape : null, opacity: num(cs.opacity) || 1, clip: /hidden|clip|auto|scroll/.test(cs.overflow), shadows: shadows(cs.boxShadow), pos: cs.position, }; }; // Scroll offset at which a media node inside a pinned track shows its // mid-sequence state (a scrubbed canvas renders frame 0 at the track's start). const pinnedCaptureAt = (el) => { let p = el.parentElement; while (p && p !== document.body) { if (getComputedStyle(p).position === "sticky" && p.parentElement) { const tr = p.parentElement.getBoundingClientRect(); if (tr.height > innerHeight * 1.5) { return Math.round(tr.top + scrollY + (tr.height - innerHeight) * 0.5); } } p = p.parentElement; } return null; }; const walk = (el) => { if (SKIP.has(el.tagName)) return null; const cs = getComputedStyle(el); const rect = el.getBoundingClientRect(); if (!isVisible(el, cs, rect)) return null; const base = { id: "n" + uid++, name: nameFor(el), tag: el.tagName.toLowerCase(), x: rect.left + scrollX, y: rect.top + scrollY, w: rect.width, h: rect.height, ...boxOf(el, cs), }; // is a transparent wrapper: it carries no src, and its own box // collapses to the inline placeholder (118px where the image is 1614px), so // measuring it instead of the inside shrinks every Builder image. if (el.tagName === "PICTURE") { const inner = el.querySelector("img"); return inner ? walk(inner) : null; } if (MEDIA.has(el.tagName)) { base.type = "MEDIA"; const at = pinnedCaptureAt(el); if (at != null) base.captureAt = at; base.mediaKind = el.tagName.toLowerCase(); const src = el.currentSrc || el.src || el.getAttribute("poster") || (el.querySelector && el.querySelector("source") ? el.querySelector("source").src : ""); base.src = src || ""; base.alt = (el.getAttribute && (el.getAttribute("alt") || el.getAttribute("aria-label"))) || ""; base.fit = cs.objectFit || "cover"; el.setAttribute("data-fx", base.id); return base; } if (isTextLeaf(el)) { base.type = "TEXT"; const pre = pseudoText(el, "::before"); const chars = charsOf(el); base.text = pre + chars.map((c) => c[0]).join("") + pseudoText(el, "::after"); base.font = fontOf(cs); const runs = runsOf(chars); // Only worth carrying when the line is not uniformly styled. if (runs.length > 1) { base.runs = runs.map((r) => ({ s: r.s + pre.length, e: r.e + pre.length, ft: r.ft })); } return base; } base.type = "FRAME"; const kids = []; const before = pseudoOverlay(el, "::before", rect); if (before) kids.push(before); for (const c of el.children) { const n = walk(c); if (n) kids.push(n); } // Bare text sitting alongside element children — e.g. a button's label next // to its arrow . Measure each run with a Range: handing it the // parent's whole box (as this used to) makes it overlap its siblings and // fools the normaliser's row/column inference, which stacked the CTA label // on top of its own arrow. if (kids.length) { for (const t of el.childNodes) { if (t.nodeType !== 3 || !t.nodeValue.trim()) continue; const rng = document.createRange(); rng.selectNodeContents(t); const tr = rng.getBoundingClientRect(); if (tr.width < 0.5 || tr.height < 0.5) continue; kids.push({ id: "n" + uid++, type: "TEXT", name: "text", tag: "span", x: tr.left + scrollX, y: tr.top + scrollY, w: tr.width, h: tr.height, text: t.nodeValue.replace(/\s+/g, " ").trim(), fills: [], strokes: [], radius: [0, 0, 0, 0], opacity: 1, clip: false, shadows: [], font: fontOf(cs), pos: "static", }); } } const after = pseudoOverlay(el, "::after", rect); if (after) kids.push(after); base.children = kids; base.display = cs.display; base.flexDir = cs.flexDirection; base.gap = [num(cs.rowGap), num(cs.columnGap)]; base.padding = [num(cs.paddingTop), num(cs.paddingRight), num(cs.paddingBottom), num(cs.paddingLeft)]; return base; }; const root = walk(document.body); return { width: document.documentElement.clientWidth, height: document.body.scrollHeight, root }; }); fs.writeFileSync(path.join(OUT, "tree.raw.json"), JSON.stringify(tree)); const media = []; (function collect(n) { if (!n) return; if (n.type === "MEDIA") media.push(n); (n.children || []).forEach(collect); })(tree.root); let captured = 0; for (const m of media) { if (m.w < 10 || m.h < 10) continue; const file = path.join(OUT, "media", m.id + ".png"); try { const loc = page.locator('[data-fx="' + m.id + '"]'); if (m.captureAt != null) { await page.evaluate((y) => window.scrollTo(0, y), m.captureAt); await page.waitForTimeout(400); } else { await loc.scrollIntoViewIfNeeded({ timeout: 4000 }); await page.waitForTimeout(120); } await loc.screenshot({ path: file, timeout: 8000, scale: "css" }); captured++; } catch (e) { // Element cannot be captured on its own (transparent svg, detached, etc.) } } await browser.close(); console.log( JSON.stringify({ out: OUT, pageHeight: tree.height, mediaNodes: media.length, captured, hiddenCandidates: reveal.candidates, revealForced: reveal.revealed, pinnedCaptures: media.filter((m) => m.captureAt != null).length, }), );