From d6d0fccbf2787291f54c89f0ce5d72bc3c0b5b6d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 10:47:01 -0700 Subject: [PATCH] fix(core): reproduce figma's vertical text trim via text-box-trim A figma text node whose box is shorter than its line-height carries vertically-trimmed (cap-to-baseline) bounds. The mapper positioned the box at those bounds but let the browser lay glyphs with half-leading, pushing them ~6px low on a 70px font (glyph-centroid measurement against figma's own render: +9.1px vs figma's +3.4px inside the same pill). Emitting text-box-trim: trim-both / text-box-edge: cap alphabetic reproduces the trim in the render engine; post-fix centroid agrees within 0.4px and the motion verifier's min window score improved 20.3 -> 25.3dB. Trim applies only to single-line trimmed text; boxes matching their line-height are untouched. Skill: component imports now include a static fidelity self-check step against figma's PNG export of the same node. Co-Authored-By: Claude Fable 5 --- packages/core/src/figma/nodeToHtml.test.ts | 39 ++++++++++++++++++++++ packages/core/src/figma/nodeToHtml.ts | 17 ++++++++++ skills-manifest.json | 2 +- skills/figma/SKILL.md | 1 + 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/core/src/figma/nodeToHtml.test.ts b/packages/core/src/figma/nodeToHtml.test.ts index 367311474..65d332e9f 100644 --- a/packages/core/src/figma/nodeToHtml.test.ts +++ b/packages/core/src/figma/nodeToHtml.test.ts @@ -102,6 +102,45 @@ describe("nodeToHtml", () => { expect(out.html).not.toContain('id="3d-object-headphones"'); }); + it("emits text-box-trim for vertically trimmed text (box height < line-height)", () => { + const out = nodeToHtml( + frame([ + { + id: "1:2", + name: "Headline", + type: "TEXT", + absoluteBoundingBox: BOX(140, 260, 304, 51), + fills: [SOLID_BLUE], + characters: "Unlocked", + style: { fontFamily: "Inter", fontWeight: 700, fontSize: 70, lineHeightPx: 66.5 }, + }, + ]), + { resolved: [], unresolved: [] }, + ); + // figma's trimmed bounds (51px box for a 66.5px line) place cap height at + // the box top; browsers overflow the glyphs below without text-box-trim + expect(out.html).toContain("text-box-trim: trim-both"); + expect(out.html).toContain("text-box-edge: cap alphabetic"); + }); + + it("does not trim text whose box matches its line-height", () => { + const out = nodeToHtml( + frame([ + { + id: "1:2", + name: "Body", + type: "TEXT", + absoluteBoundingBox: BOX(140, 260, 304, 39), + fills: [SOLID_BLUE], + characters: "Subtitle", + style: { fontFamily: "Inter", fontWeight: 400, fontSize: 32, lineHeightPx: 38.4 }, + }, + ]), + { resolved: [], unresolved: [] }, + ); + expect(out.html).not.toContain("text-box-trim"); + }); + it("emits var() with literal fallback for resolved bindings", () => { const out = nodeToHtml( frame([ diff --git a/packages/core/src/figma/nodeToHtml.ts b/packages/core/src/figma/nodeToHtml.ts index 393af42c4..e1779d77c 100644 --- a/packages/core/src/figma/nodeToHtml.ts +++ b/packages/core/src/figma/nodeToHtml.ts @@ -152,6 +152,23 @@ function textCss(node: FigmaNodeDocument, styles: string[]): void { if (typeof s.lineHeightPx === "number") styles.push(`line-height: ${round(s.lineHeightPx)}px`); if (typeof s.letterSpacing === "number" && s.letterSpacing !== 0) styles.push(`letter-spacing: ${round(s.letterSpacing)}px`); + if (isVerticallyTrimmed(node, s.lineHeightPx)) { + styles.push("text-box-trim: trim-both", "text-box-edge: cap alphabetic"); + } +} + +/** + * Vertical trim: a figma text box SHORTER than its line-height is + * cap-height-trimmed bounds. Browsers place glyphs with half-leading and + * overflow the short box downward (~6px low on a 70px font, measured + * against figma's own render). text-box-trim reproduces figma's trim in + * the render engine (Chrome). Single-line text only. + */ +function isVerticallyTrimmed(node: FigmaNodeDocument, lineHeightPx: unknown): boolean { + if (typeof lineHeightPx !== "number") return false; + const box = boxOf(node); + if (box === null || box.height >= lineHeightPx - 1) return false; + return typeof node.characters === "string" && !node.characters.includes("\n"); } interface RenderContext { diff --git a/skills-manifest.json b/skills-manifest.json index 95f09abfe..420885a7b 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 18 }, "figma": { - "hash": "431580ce359639f5", + "hash": "c2929c6cc7ca35b3", "files": 2 }, "general-video": { diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index d75e766b6..c0ceb4cb8 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -78,6 +78,7 @@ hyperframes figma component '' Node tree → editable HTML at exact figma geometry, packaged as a registry item under `compositions/components//`. Vectors/boolean-ops auto-rasterize via Phase-1 export. Binding pass (spec §7.1, exact-ID only — never value matching): +- **Static fidelity self-check (mandatory for hero content)**: after importing, render the fragment and compare against figma's own pixels — `figma asset --format png` is the ground truth. Text is the known drift axis: a figma text box shorter than its line-height is vertically-trimmed bounds (the mapper emits `text-box-trim` for these; measured drift without it was ~6px on a 70px font). If the comparison shows drift the mapper doesn't cover, report it — don't hand-tweak the fragment silently. - Fill bound to an **imported** token → `var(--slug, #literal)` — brand refresh propagates. - Bound to an **unknown** token → literal + `data-figma-unresolved` flag. The command tells you; offer the user: run `tokens` on the source (or library) file, then re-import the component to link them. Ask **once** per unknown library which file it is — never guess, never match by hex.