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:
Vance Ingalls
2026-07-09 00:47:03 -07:00
co-authored by Claude Fable 5
parent 31f0810be8
commit dfe63af3ae
4 changed files with 120 additions and 8 deletions
@@ -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([
+17 -7
View File
@@ -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 {