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 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-09 10:47:01 -07:00
co-authored by Claude Fable 5
parent ccab6207c4
commit d6d0fccbf2
4 changed files with 58 additions and 1 deletions
@@ -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([
+17
View File
@@ -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 {