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 <[email protected]>
This commit is contained in:
Vance Ingalls
2026-07-09 10:47:01 -07:00
co-authored by Claude Fable 5
parent 1089aad91c
commit afe89df783
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 {
+1 -1
View File
@@ -10,7 +10,7 @@
"files": 18
},
"figma": {
"hash": "431580ce359639f5",
"hash": "c2929c6cc7ca35b3",
"files": 2
},
"general-video": {
+1
View File
@@ -78,6 +78,7 @@ hyperframes figma component '<url-or-fileKey:nodeId>'
Node tree → editable HTML at exact figma geometry, packaged as a registry item under `compositions/components/<name>/`. 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 <same node> --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.