mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
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:
co-authored by
Claude Fable 5
parent
1089aad91c
commit
afe89df783
@@ -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([
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"files": 18
|
||||
},
|
||||
"figma": {
|
||||
"hash": "431580ce359639f5",
|
||||
"hash": "c2929c6cc7ca35b3",
|
||||
"files": 2
|
||||
},
|
||||
"general-video": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user