mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core,cli): parent-relative figma child geometry; groups stop rejecting subcommand flags
Both found running the brand-loop guide end-to-end against the Simple Design System: - nodeToHtml subtracted the ROOT origin from every node's absolute bounds, but CSS absolute positioning resolves against the nearest positioned ancestor — every nesting level re-added its ancestors' offsets, drifting nested content down-right and pushing deep children off-frame (hero buttons invisible, pricing grid collapsed to one card). Children now subtract their PARENT's box; regression test with a two-level tree. - trackCommandFailures asserted unknown flags against the command group's own (flagless) arg table even when the group was delegating to a subcommand, so `figma component <ref> --name x` imported and THEN threw "Unknown flag: --name". The assertion is now skipped when the first positional names a subcommand; leaf and non-delegating behavior is unchanged and covered by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
31f0810be8
commit
dfe63af3ae
@@ -52,6 +52,38 @@ describe("nodeToHtml", () => {
|
||||
expect(out.html).toContain("background-color: #0066FF");
|
||||
});
|
||||
|
||||
it("positions nested children relative to their PARENT, not the root", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:2",
|
||||
name: "Group",
|
||||
type: "FRAME",
|
||||
absoluteBoundingBox: BOX(600, 400, 300, 200),
|
||||
children: [
|
||||
{
|
||||
id: "1:3",
|
||||
name: "Inner",
|
||||
type: "RECTANGLE",
|
||||
absoluteBoundingBox: BOX(620, 440, 100, 40),
|
||||
fills: [SOLID_BLUE],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
{ resolved: [], unresolved: [] },
|
||||
);
|
||||
// Group at canvas (600,400) inside root (100,200) → left 500, top 200.
|
||||
expect(out.html).toContain("left: 500px");
|
||||
expect(out.html).toContain("top: 200px");
|
||||
// Inner at canvas (620,440) inside Group (600,400) → left 20, top 40 —
|
||||
// NOT root-relative 520/240, which double-offsets when CSS resolves
|
||||
// absolute position against the positioned parent.
|
||||
expect(out.html).toContain("left: 20px");
|
||||
expect(out.html).toContain("top: 40px");
|
||||
expect(out.html).not.toContain("left: 520px");
|
||||
});
|
||||
|
||||
it("emits var() with literal fallback for resolved bindings", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
|
||||
@@ -186,7 +186,7 @@ function unresolvedAttr(node: FigmaNodeDocument, ctx: RenderContext): string {
|
||||
return ` data-figma-unresolved="${escapeHtml(props.join(" "))}"`;
|
||||
}
|
||||
|
||||
function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolean): string[] {
|
||||
function geometryCss(node: FigmaNodeDocument, parentBox: Box, isRoot: boolean): string[] {
|
||||
const box = boxOf(node);
|
||||
const styles: string[] = [];
|
||||
if (!box) return styles;
|
||||
@@ -197,10 +197,14 @@ function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolea
|
||||
`height: ${round(box.height)}px`,
|
||||
);
|
||||
} else {
|
||||
// CSS absolute positioning is relative to the nearest positioned
|
||||
// ancestor — the PARENT's box, not the root origin. Subtracting the root
|
||||
// for every depth double-offsets nested children (each level re-adds its
|
||||
// ancestors' offsets), drifting content down-right and off-frame.
|
||||
styles.push(
|
||||
"position: absolute",
|
||||
`left: ${round(box.x - ctx.origin.x)}px`,
|
||||
`top: ${round(box.y - ctx.origin.y)}px`,
|
||||
`left: ${round(box.x - parentBox.x)}px`,
|
||||
`top: ${round(box.y - parentBox.y)}px`,
|
||||
`width: ${round(box.width)}px`,
|
||||
`height: ${round(box.height)}px`,
|
||||
);
|
||||
@@ -243,10 +247,15 @@ function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
|
||||
// instead of a RangeError; real figma frames are nowhere near this deep.
|
||||
const MAX_DEPTH = 500;
|
||||
|
||||
function renderChildren(node: FigmaNodeDocument, ctx: RenderContext, depth: number): string {
|
||||
function renderChildren(
|
||||
node: FigmaNodeDocument,
|
||||
ctx: RenderContext,
|
||||
depth: number,
|
||||
parentBox: Box,
|
||||
): string {
|
||||
const childHtml: string[] = [];
|
||||
for (const child of childDocuments(node)) {
|
||||
const rendered = renderNodeHtml(child, ctx, false, depth + 1);
|
||||
const rendered = renderNodeHtml(child, ctx, false, depth + 1, parentBox);
|
||||
if (rendered.length > 0) childHtml.push(rendered);
|
||||
}
|
||||
return childHtml.length > 0 ? `\n${childHtml.join("\n")}\n` : "";
|
||||
@@ -257,11 +266,12 @@ function renderNodeHtml(
|
||||
ctx: RenderContext,
|
||||
isRoot: boolean,
|
||||
depth = 0,
|
||||
parentBox: Box = ctx.origin,
|
||||
): string {
|
||||
if (node.visible === false || depth > MAX_DEPTH) return "";
|
||||
const slug = uniqueSlug(ctx, node.name);
|
||||
const style = escapeHtml(
|
||||
[...geometryCss(node, ctx, isRoot), ...decorationCss(node, ctx)].join("; "),
|
||||
[...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx)].join("; "),
|
||||
);
|
||||
// data-hf-snippet marks the file as a mountable fragment, not a standalone
|
||||
// composition — the project linter skips composition-root rules for it.
|
||||
@@ -278,7 +288,7 @@ function renderNodeHtml(
|
||||
return `<div ${idAttrs} style="${style}">${text}</div>`;
|
||||
}
|
||||
|
||||
return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth)}</div>`;
|
||||
return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth, boxOf(node) ?? parentBox)}</div>`;
|
||||
}
|
||||
|
||||
export interface NodeToHtmlOptions {
|
||||
|
||||
Reference in New Issue
Block a user