diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index c72ff43304..760706db76 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -2266,6 +2266,7 @@ export const editorChromeBridgeScript: string = `"use strict"; } var passiveSelectionEls = []; var passiveSelectionOverlays = []; + var multiSelectionBoundsOverlay = null; var activeMarqueeSelection = null; var activeTextEditEl = null; var activeTextEditOriginalMinWidth = ""; @@ -2397,7 +2398,6 @@ export const editorChromeBridgeScript: string = `"use strict"; overlay.setAttribute("data-agent-native-soft-chrome", "true"); } overlay.style.cssText = style === "soft" ? "position:fixed;pointer-events:none;z-index:99996;border:1px solid color-mix(in srgb,var(--design-editor-accent-color) 64%,transparent);background:color-mix(in srgb,var(--design-editor-accent-color) 5%,transparent);display:none;box-sizing:border-box;" : "position:fixed;pointer-events:none;z-index:99996;border:1.5px solid var(--design-editor-accent-color);background:transparent;display:none;box-sizing:border-box;"; - if (style !== "soft") appendPassiveSelectionHandles(overlay); document.body.appendChild(overlay); return overlay; } @@ -2430,6 +2430,7 @@ export const editorChromeBridgeScript: string = `"use strict"; passiveSelectionOverlays.push(overlay); positionOverlay(overlay, el); }); + positionMultiSelectionBounds(); } function preservePreviousSelectedElementForShiftClick(previous, next, e) { if (!e?.shiftKey || !previous || !next || previous === next || !document.documentElement.contains(previous) || isLayerInteractionBlocked(previous)) { @@ -3401,6 +3402,67 @@ export const editorChromeBridgeScript: string = `"use strict"; overlay.style.transformOrigin = "50% 50%"; return true; } + function ensureMultiSelectionBoundsOverlay() { + if (multiSelectionBoundsOverlay) return multiSelectionBoundsOverlay; + var overlay = document.createElement("div"); + overlay.setAttribute("data-agent-native-edit-overlay", "multi-selection"); + overlay.setAttribute("data-agent-native-multi-selection-bounds", "true"); + overlay.style.cssText = "position:fixed;pointer-events:none;z-index:99996;border:1.5px solid var(--design-editor-accent-color);background:transparent;display:none;box-sizing:border-box;"; + appendPassiveSelectionHandles(overlay); + document.body.appendChild(overlay); + multiSelectionBoundsOverlay = overlay; + return overlay; + } + function positionMultiSelectionBounds() { + var members = []; + if (selectedEl && document.documentElement.contains(selectedEl)) { + members.push(selectedEl); + } + passiveSelectionEls.forEach(function(el) { + if (el && document.documentElement.contains(el)) members.push(el); + }); + if (members.length < 2 || selectionChromeHidden) { + if (multiSelectionBoundsOverlay) { + multiSelectionBoundsOverlay.style.display = "none"; + } + return; + } + var rects = members.map(function(el) { + return el.getBoundingClientRect(); + }); + var left = Math.min.apply( + null, + rects.map(function(r) { + return r.left; + }) + ); + var top = Math.min.apply( + null, + rects.map(function(r) { + return r.top; + }) + ); + var right = Math.max.apply( + null, + rects.map(function(r) { + return r.right; + }) + ); + var bottom = Math.max.apply( + null, + rects.map(function(r) { + return r.bottom; + }) + ); + var overlay = ensureMultiSelectionBoundsOverlay(); + overlay.style.display = "block"; + overlay.style.transform = "none"; + overlay.style.left = left + "px"; + overlay.style.top = top + "px"; + overlay.style.width = Math.max(0, right - left) + "px"; + overlay.style.height = Math.max(0, bottom - top) + "px"; + scalePassiveSelectionOverlay(overlay); + } function positionOverlay(overlay, el) { if (!el || !document.documentElement.contains(el)) { overlay.style.display = "none"; @@ -3460,6 +3522,7 @@ export const editorChromeBridgeScript: string = `"use strict"; var overlay = passiveSelectionOverlays[index]; if (overlay) positionOverlay(overlay, el); }); + positionMultiSelectionBounds(); positionGradientOverlay(); syncOverlayObservers(); } @@ -3725,10 +3788,12 @@ export const editorChromeBridgeScript: string = `"use strict"; if (isOverlayElement(target)) continue; if (isLayerInteractionBlocked(target)) { lastEditorPointWasBlocked = true; + dndLog("select:blocked", { el: getSelector(target) }); return null; } return target; } + dndLog("select:nothing-at-point", { x: clientX, y: clientY }); return null; } function stopNativeInteraction(e) { @@ -5319,8 +5384,7 @@ export const editorChromeBridgeScript: string = `"use strict"; function isAbsolutePrimitiveContainer(el) { if (!el || (el.tagName || "").toLowerCase() !== "div") return false; var primitive = (el.getAttribute("data-an-primitive") || el.getAttribute("data-agent-native-primitive") || "").toLowerCase(); - if (primitive !== "rectangle" && primitive !== "rect" && primitive !== "frame") - return false; + if (primitive !== "frame") return false; var cs = window.getComputedStyle(el); return cs.position === "absolute" || cs.position === "fixed"; } @@ -5584,6 +5648,8 @@ export const editorChromeBridgeScript: string = `"use strict"; if (!el || el === document.documentElement) return false; if (isOverlayElement(el) || isLayerInteractionBlocked(el)) return false; if (el === document.body) return true; + var primitiveKind = el.getAttribute("data-an-primitive"); + if (primitiveKind && primitiveKind !== "frame") return false; var tag = (el.tagName || "").toLowerCase(); if (BRIDGE_LEAF_TAGS.indexOf(tag) !== -1 || BRIDGE_TEXT_TAGS.indexOf(tag) !== -1) return false; @@ -8823,6 +8889,7 @@ export const editorChromeBridgeScript: string = `"use strict"; } else { positionOverlay(selectionOverlay, target); } + positionMultiSelectionBounds(); if (hoveredEl === selectedEl) highlightOverlay.style.display = "none"; if (selectionChangedByHost) { postElementSelect(target); diff --git a/templates/design/app/components/design/DesignCanvas.responsive-selection.test.ts b/templates/design/app/components/design/DesignCanvas.responsive-selection.test.ts index 35b1087ad4..15aa271404 100644 --- a/templates/design/app/components/design/DesignCanvas.responsive-selection.test.ts +++ b/templates/design/app/components/design/DesignCanvas.responsive-selection.test.ts @@ -36,9 +36,15 @@ describe("responsive mirrored selection chrome", () => { expect(bridgeSource).toContain( "color-mix(in srgb,var(--design-editor-accent-color) 64%,transparent)", ); - expect(bridgeSource).toContain( + // Handles live on the combined multi-selection bounds box, so no + // per-element passive overlay — soft or default — grows its own. + expect(bridgeSource).not.toContain( 'if (style !== "soft") appendPassiveSelectionHandles(overlay);', ); + expect( + bridgeSource.split("appendPassiveSelectionHandles(overlay)").length - 1, + "handles must be appended only to the combined bounds overlay", + ).toBe(1); expect(bridgeSource).toContain( 'e.data.passiveSelectionStyle === "soft" ? "soft" : "default"', ); diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index a4467c0a07..5eca6c3861 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -4444,6 +4444,18 @@ export function DesignCanvas({ readOnly, })} data-design-preview-iframe + onLoad={(event) => { + // The bridge logs into the IFRAME console and cannot read + // import.meta.env, so dev has to switch it on from out here. + if (!import.meta.env?.DEV) return; + try { + const win = event.currentTarget.contentWindow as + | (Window & { __DND_DEBUG?: boolean }) + | null; + if (win) win.__DND_DEBUG = true; + // coercion-ok: a cross-origin preview exposes no contentWindow + } catch {} + }} {...{ [SESSION_REPLAY_IFRAME_ATTRIBUTE]: !externalPreviewUrl ? "" diff --git a/templates/design/app/components/design/LayersPanel.tsx b/templates/design/app/components/design/LayersPanel.tsx index 791acd7bca..7e5bc696ed 100644 --- a/templates/design/app/components/design/LayersPanel.tsx +++ b/templates/design/app/components/design/LayersPanel.tsx @@ -1782,6 +1782,9 @@ const LayerRow = memo(function LayerRow({ // doing so. const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " " || event.key === "Space") { + // Figma drills into the selection on Enter and walks up on Shift+Enter; + // re-selecting an already-selected row here would swallow both. + if (event.key === "Enter" && isSelected) return; event.preventDefault(); if (!selectable) return; onSelect(node.id, { @@ -1797,12 +1800,21 @@ const LayerRow = memo(function LayerRow({ onStartRename(node); return; } - if (event.key === "ArrowRight" && hasChildren && !isExpanded) { + // Only the PLAIN chord toggles the chevron: Shift+Arrow is Figma's big + // nudge and every modified arrow belongs to the canvas hotkeys below. + const plainArrow = + !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey; + if ( + plainArrow && + event.key === "ArrowRight" && + hasChildren && + !isExpanded + ) { event.preventDefault(); onToggleExpanded(node.id, true); return; } - if (event.key === "ArrowLeft" && hasChildren && isExpanded) { + if (plainArrow && event.key === "ArrowLeft" && hasChildren && isExpanded) { event.preventDefault(); onToggleExpanded(node.id, false); return; @@ -2077,17 +2089,22 @@ const LayerRow = memo(function LayerRow({ > {activeDrop === "before" ? ( ) : null} {activeDrop === "after" ? ( ) : null}
{ ).toBeUndefined(); }); }); + +describe("board surface background follows the editor theme", () => { + const preview = (background?: string) => + getBoardSurfaceStaticPreviewContent({ + html: `
`, + logicalGeometry: { x: 0, y: 0, width: 1000, height: 1000 }, + viewport: { width: 500, height: 500 }, + background, + }); + + it("paints the themed canvas colour when one is supplied", () => { + // The board is its own iframe and cannot read the host's CSS vars, so a + // hardcoded dark fill made the canvas black in the light theme. + const content = preview("hsl(0 0% 92%)"); + expect(content).toContain("hsl(0 0% 92%)"); + expect(content).not.toContain("hsl(0, 0%, 10%)"); + }); + + it("falls back to the dark default when no theme colour is resolved", () => { + expect(preview()).toContain("hsl(0, 0%, 10%)"); + expect(preview(" ")).toContain("hsl(0, 0%, 10%)"); + }); +}); diff --git a/templates/design/app/components/design/MultiScreenCanvas.tsx b/templates/design/app/components/design/MultiScreenCanvas.tsx index 35d9138e92..ef2607f1f9 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.tsx @@ -51,6 +51,7 @@ import { IconHandClick, IconPlus, } from "@tabler/icons-react"; +import { useTheme } from "next-themes"; import { memo, useRef, @@ -89,6 +90,7 @@ import { } from "./design-canvas/content-size-report"; import { appendHitTestResponder } from "./design-canvas/hit-test"; import { withLocalRuntimes } from "./design-canvas/local-runtime"; +import { roundGeo, trace } from "./design-trace"; import { DesignCanvas } from "./DesignCanvas"; import { dndHostLog } from "./dnd-debug"; import { @@ -473,6 +475,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ onPrimitiveCreated, onPrimitiveReparent, onCreateScreenFrame, + frameToolDraws = "frame", onDeleteSelection, onNudgeSelection, onZoomChange, @@ -522,6 +525,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ onDropFiles, cameraCommand, }: MultiScreenCanvasProps) { + const { resolvedTheme } = useTheme(); const t = useT(); const surfaceRef = useRef(null); const [pan, setPan] = useState({ x: 0, y: 0 }); @@ -641,6 +645,17 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ height: surfaceSize.height / scale, }; }, [canvasZoom, pan.x, pan.y, surfaceSize.height, surfaceSize.width]); + // The board iframe cannot read the host's CSS vars, so the themed canvas + // colour has to be resolved out here or the board stays dark in light mode. + const boardSurfaceBackground = useMemo(() => { + if (typeof window === "undefined") return BOARD_SURFACE_BACKGROUND; + const themed = window + .getComputedStyle(document.documentElement) + .getPropertyValue("--design-editor-canvas-bg") + .trim(); + return themed || BOARD_SURFACE_BACKGROUND; + }, [resolvedTheme]); + const boardSurfaceRenderGeometry = useMemo(() => { if (!boardFrameGeometry) return undefined; const focusGeometry = boardSurfaceFocusPoint @@ -692,8 +707,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ html: boardSurfaceHtml, logicalGeometry: boardFrameGeometry, viewport: boardStaticPreviewViewport, + background: boardSurfaceBackground, }); - }, [boardSurfaceHtml, boardFrameGeometry, boardStaticPreviewViewport]); + }, [ + boardSurfaceHtml, + boardFrameGeometry, + boardStaticPreviewViewport, + boardSurfaceBackground, + ]); const showBoardStaticPreview = Boolean( boardFrameGeometry && boardSurfaceRenderGeometry && @@ -2189,6 +2210,25 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const sourceIsBoard = sourceScreenId === boardFileId; setCrossScreenSourceIsBoard(sourceIsBoard); const target = getFrameEntryAtPoint(boardPoint); + trace("drop", "resolve-target", { + pointerInCanvasUnits: { + x: Math.round(boardPoint.x), + y: Math.round(boardPoint.y), + }, + hitFrame: target?.id ?? null, + sourceScreen: sourceScreenId, + sourceIsBoard, + frames: Object.entries(frameGeometryRef.current ?? {}).map( + ([id, g]: [string, any]) => + `${id.slice(0, 6)} @ ${Math.round(g.x)},${Math.round(g.y)} ${Math.round(g.width)}x${Math.round(g.height)}`, + ), + verdict: + target && target.id !== sourceScreenId + ? "will drop into that frame" + : target + ? "hit the SOURCE frame, so no cross-screen move" + : "NO frame under the pointer — drop cannot resolve a screen", + }); if (target && target.id !== sourceScreenId) { const nextTarget = { id: target.id, geometry: target.geometry }; if (crossScreenTargetRef.current?.id !== nextTarget.id) { @@ -2261,6 +2301,29 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const hasIdentifier = !!(payload.selector || payload.sourceId); if (!hasIdentifier || !sourceScreenId) return; if (!lastBoardPoint) return; + // No candidate means the pointer never left the source screen, so the + // bridge already handled it as an in-place reorder. Falling back to the + // board here re-persists the move against a file that does not contain + // the element, which fails to resolve and silently discards the drop. + const sourceFrameGeometry = frameGeometryRef.current?.[sourceScreenId]; + const droppedInsideSourceScreen = + !candidate && + !!sourceFrameGeometry && + lastBoardPoint.x >= sourceFrameGeometry.x && + lastBoardPoint.x <= sourceFrameGeometry.x + sourceFrameGeometry.width && + lastBoardPoint.y >= sourceFrameGeometry.y && + lastBoardPoint.y <= sourceFrameGeometry.y + sourceFrameGeometry.height; + if (droppedInsideSourceScreen) return; + trace("drop", "finalize", { + candidate: candidate?.id ?? null, + sourceScreen: sourceScreenId, + droppedInsideSourceScreen, + outcome: droppedInsideSourceScreen + ? "discarded — pointer never left the source screen" + : candidate + ? "moving into candidate" + : "no candidate; falling back to the board", + }); const targetCandidate = candidate ?? (boardFileId && sourceScreenId !== boardFileId && boardFrameGeometry @@ -2927,6 +2990,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const viewportWidth = iframe?.clientWidth || metadata.width; const viewportHeight = iframe?.clientHeight || metadata.height; const infos = await requestSelectableElementInfos(entry.id); + // entry.geometry.height trails the measured auto-height, and an + // independent y-scale off it squashes every child rect. + const contentScale = + entry.geometry.width / Math.max(1, viewportWidth); + const renderedFrame = { + ...entry.geometry, + height: viewportHeight * contentScale, + }; return infos.map((info) => ({ screenId: entry.id, info, @@ -2937,7 +3008,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ width: info.boundingRect.width, height: info.boundingRect.height, }, - entry.geometry, + renderedFrame, { width: viewportWidth, height: viewportHeight }, ), frameGeometry: entry.geometry, @@ -3718,11 +3789,27 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ getScreenMetadata?.(targetScreen), ) : undefined; + // Metadata defaults to 1280x2560 while the renderer lays the iframe out + // at the frame's own size; trusting metadata scales every drawn + // primitive by metadataWidth/frameWidth. Same precedence as the marquee. + const targetIframe = targetScreen + ? surfaceRef.current?.querySelector( + `[data-screen-iframe-id="${CSS.escape(getActiveScreenIframeId(targetScreen))}"]`, + ) + : null; + const measuredMetadata = + targetMetadata && targetIframe?.clientWidth && targetIframe.clientHeight + ? { + ...targetMetadata, + width: targetIframe.clientWidth, + height: targetIframe.clientHeight, + } + : targetMetadata; const localPrimitive = draftPrimitiveToInsert( draft, targetFrame.geometry, - targetMetadata, + measuredMetadata, ); const persisted = onCreatePrimitive(targetFrame.id, localPrimitive); if (!persisted) { @@ -4436,6 +4523,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // originating screen. if ( state.tool === "frame" && + frameToolDraws === "screen" && !state.originFrameId && onCreateScreenFrame ) { @@ -4459,9 +4547,19 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ 0, ) : null; - onCreateScreenFrame( - clampFrameGeometryToViewport(draftGeometry, viewportBounds), + const screenGeometry = clampFrameGeometryToViewport( + draftGeometry, + viewportBounds, ); + trace("screen", "create-from-frame-tool", { + why: "frame tool started on empty canvas, so it becomes a SCREEN", + drawn: roundGeo(draftGeometry), + committed: roundGeo(screenGeometry), + clamped: + Math.round(draftGeometry.x) !== Math.round(screenGeometry.x) || + Math.round(draftGeometry.y) !== Math.round(screenGeometry.y), + }); + onCreateScreenFrame(screenGeometry); if (activeTool === undefined) { setLocalActiveTool("move"); } @@ -4469,6 +4567,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ finishDrag(); return; } + trace("draw", "commit-primitive", { + tool: state.tool, + startedInScreen: state.originFrameId ?? "(empty canvas → board)", + }); const nextDraft = createDraftPrimitive({ tool: state.tool, start: state.originCanvas, @@ -4503,6 +4605,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ activeTool, commitDraftPrimitive, finishDrag, + frameToolDraws, getCanvasPoint, getFrameEntryAtPoint, installDragListeners, @@ -7474,6 +7577,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const chromeScale = scale > 0 ? 1 / scale : 1; const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM; const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool); + const lastTracedToolRef = useRef(null); + if (lastTracedToolRef.current !== effectiveTool) { + lastTracedToolRef.current = effectiveTool; + trace("tool", "active-tool", { tool: effectiveTool }); + } useEffect(() => { effectiveToolRef.current = effectiveTool; }, [effectiveTool]); @@ -7624,7 +7732,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ metadata.previewUrl ?? getPreviewUrl(screen.content) ); const autoHeight = - isInlineScreen && measuredPrimaryHeight + isInlineScreen && measuredPrimaryHeight && !metadata.heightPinned ? Math.max( deviceViewportFloorForWidth(metadata.width), rawGeometry.height ?? 0, @@ -8055,7 +8163,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ zoom={100} deviceFrame="none" boardSurface - embeddedFrameBackground={BOARD_SURFACE_BACKGROUND} + embeddedFrameBackground={boardSurfaceBackground} embeddedFrame={{ viewportWidth: Math.max(1, Math.round(boardW)), viewportHeight: Math.max(1, Math.round(boardH)), diff --git a/templates/design/app/components/design/bridge/bridge.guard.spec.ts b/templates/design/app/components/design/bridge/bridge.guard.spec.ts index feaf9df500..21feb1ae3d 100644 --- a/templates/design/app/components/design/bridge/bridge.guard.spec.ts +++ b/templates/design/app/components/design/bridge/bridge.guard.spec.ts @@ -5186,7 +5186,7 @@ it( }); page.on("pageerror", (err) => pageErrors.push(err.message)); - // #container is a canvas rectangle primitive (data-an-primitive): + // #container is a canvas frame primitive (data-an-primitive): // dropping onto it resolves to dropMode "absolute-container", which // keeps the member position:absolute — the member's inline left/top // must therefore be converted from its OLD containing-block space @@ -5210,7 +5210,7 @@ it( -
+
Note
`); @@ -7647,7 +7647,7 @@ it( -
+
Drag me
`); diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index d19a749a49..b945fb1715 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -2749,6 +2749,9 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; var passiveSelectionEls: Element[] = []; var passiveSelectionOverlays: HTMLElement[] = []; + // Figma draws ONE bounding box with handles around a multi-selection; the + // per-element overlays above are the thin outlines inside it. + var multiSelectionBoundsOverlay: HTMLElement | null = null; var activeMarqueeSelection: { startX: number; startY: number; @@ -3042,7 +3045,6 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; style === "soft" ? "position:fixed;pointer-events:none;z-index:99996;border:1px solid color-mix(in srgb,var(--design-editor-accent-color) 64%,transparent);background:color-mix(in srgb,var(--design-editor-accent-color) 5%,transparent);display:none;box-sizing:border-box;" : "position:fixed;pointer-events:none;z-index:99996;border:1.5px solid var(--design-editor-accent-color);background:transparent;display:none;box-sizing:border-box;"; - if (style !== "soft") appendPassiveSelectionHandles(overlay); document.body.appendChild(overlay); return overlay; } @@ -3088,6 +3090,9 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; passiveSelectionOverlays.push(overlay); positionOverlay(overlay, el); }); + // Selection changes do not go through refreshOverlays, so the combined + // bounds must be recomputed here too. + positionMultiSelectionBounds(); } function preservePreviousSelectedElementForShiftClick( @@ -4536,6 +4541,70 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; return true; } + function ensureMultiSelectionBoundsOverlay(): HTMLElement { + if (multiSelectionBoundsOverlay) return multiSelectionBoundsOverlay; + var overlay = document.createElement("div"); + overlay.setAttribute("data-agent-native-edit-overlay", "multi-selection"); + overlay.setAttribute("data-agent-native-multi-selection-bounds", "true"); + overlay.style.cssText = + "position:fixed;pointer-events:none;z-index:99996;border:1.5px solid var(--design-editor-accent-color);background:transparent;display:none;box-sizing:border-box;"; + appendPassiveSelectionHandles(overlay); + document.body.appendChild(overlay); + multiSelectionBoundsOverlay = overlay; + return overlay; + } + + function positionMultiSelectionBounds(): void { + var members: Element[] = []; + if (selectedEl && document.documentElement.contains(selectedEl)) { + members.push(selectedEl); + } + passiveSelectionEls.forEach(function (el) { + if (el && document.documentElement.contains(el)) members.push(el); + }); + if (members.length < 2 || selectionChromeHidden) { + if (multiSelectionBoundsOverlay) { + multiSelectionBoundsOverlay.style.display = "none"; + } + return; + } + var rects = members.map(function (el) { + return (el as HTMLElement).getBoundingClientRect(); + }); + var left = Math.min.apply( + null, + rects.map(function (r) { + return r.left; + }), + ); + var top = Math.min.apply( + null, + rects.map(function (r) { + return r.top; + }), + ); + var right = Math.max.apply( + null, + rects.map(function (r) { + return r.right; + }), + ); + var bottom = Math.max.apply( + null, + rects.map(function (r) { + return r.bottom; + }), + ); + var overlay = ensureMultiSelectionBoundsOverlay(); + overlay.style.display = "block"; + overlay.style.transform = "none"; + overlay.style.left = left + "px"; + overlay.style.top = top + "px"; + overlay.style.width = Math.max(0, right - left) + "px"; + overlay.style.height = Math.max(0, bottom - top) + "px"; + scalePassiveSelectionOverlay(overlay); + } + function positionOverlay(overlay: HTMLElement, el: Element): void { if (!el || !document.documentElement.contains(el)) { overlay.style.display = "none"; @@ -4603,6 +4672,7 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; var overlay = passiveSelectionOverlays[index]; if (overlay) positionOverlay(overlay, el); }); + positionMultiSelectionBounds(); positionGradientOverlay(); syncOverlayObservers(); } @@ -5000,10 +5070,12 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; if (isOverlayElement(target)) continue; if (isLayerInteractionBlocked(target)) { lastEditorPointWasBlocked = true; + dndLog("select:blocked", { el: getSelector(target) }); return null; } return target; } + dndLog("select:nothing-at-point", { x: clientX, y: clientY }); return null; } @@ -7285,12 +7357,9 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; el.getAttribute("data-agent-native-primitive") || "" ).toLowerCase(); - if ( - primitive !== "rectangle" && - primitive !== "rect" && - primitive !== "frame" - ) - return false; + // Frames adopt; a rectangle is a vector shape and never becomes a + // container (same contract appendCanvasPrimitiveToHtml enforces on draw). + if (primitive !== "frame") return false; var cs = window.getComputedStyle(el); return cs.position === "absolute" || cs.position === "fixed"; } @@ -7685,6 +7754,9 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; if (!el || el === document.documentElement) return false; if (isOverlayElement(el) || isLayerInteractionBlocked(el)) return false; if (el === document.body) return true; + // Figma's shape primitives are vectors; only a frame adopts children. + var primitiveKind = el.getAttribute("data-an-primitive"); + if (primitiveKind && primitiveKind !== "frame") return false; var tag = (el.tagName || "").toLowerCase(); // Reject leaf/text tags — they cannot accept children if ( @@ -12519,6 +12591,9 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; } else { positionOverlay(selectionOverlay, target); } + // The combined box spans selectedEl plus the passive selection, so a + // host-driven change leaves it on the previous geometry without this. + positionMultiSelectionBounds(); if (hoveredEl === selectedEl) highlightOverlay.style.display = "none"; // A host-driven selection (e.g. picking a layer in the Layers panel) // only ever moved the overlay above — it never sent the rich diff --git a/templates/design/app/components/design/design-trace.ts b/templates/design/app/components/design/design-trace.ts new file mode 100644 index 0000000000..1b4105b9d1 --- /dev/null +++ b/templates/design/app/components/design/design-trace.ts @@ -0,0 +1,120 @@ +// Interaction trace, on by default in dev. From the top-frame console: +// `__DESIGN_TRACE = false` silences it; `__designTrace.dump()` prints the +// whole story to copy; `.clear()` and `.only("drop")` narrow it. +declare global { + interface Window { + __DESIGN_TRACE?: boolean; + __designTrace?: { + dump: () => string; + clear: () => void; + only: (area?: string) => void; + entries: () => TraceEntry[]; + }; + } +} + +export type TraceArea = + | "tool" + | "draw" + | "screen" + | "select" + | "drag" + | "drop" + | "persist" + | "structure" + | "history"; + +export interface TraceEntry { + t: number; + area: TraceArea; + event: string; + data?: unknown; +} + +const MAX_ENTRIES = 2000; +const entries: TraceEntry[] = []; +let areaFilter: string | undefined; +let startedAt = 0; + +function enabled(): boolean { + if (typeof window === "undefined") return false; + if (window.__DESIGN_TRACE === false) return false; + return window.__DESIGN_TRACE === true || import.meta.env?.DEV === true; +} + +function ensureControls(): void { + if (typeof window === "undefined" || window.__designTrace) return; + window.__designTrace = { + entries: () => entries.slice(), + clear: () => { + entries.length = 0; + startedAt = 0; + }, + only: (area?: string) => { + areaFilter = area; + }, + dump: () => + entries + .map((e) => { + const body = e.data === undefined ? "" : ` ${safeJson(e.data)}`; + return `+${String(e.t).padStart(6)}ms [${e.area}:${e.event}]${body}`; + }) + .join("\n"), + }; +} + +/** Never let a circular ref or a DOM node break a gesture. */ +function safeJson(value: unknown): string { + try { + return JSON.stringify(value, (_key, v) => + v instanceof Element ? v.tagName.toLowerCase() : v, + ); + } catch { + return String(value); + } +} + +export function trace(area: TraceArea, event: string, data?: unknown): void { + if (!enabled()) return; + try { + ensureControls(); + const now = Date.now(); + if (!startedAt) startedAt = now; + const entry: TraceEntry = { t: now - startedAt, area, event, data }; + entries.push(entry); + if (entries.length > MAX_ENTRIES) entries.shift(); + if (areaFilter && areaFilter !== area) return; + const tag = `%c[${area}:${event}]`; + const style = "font-weight:bold"; + if (data === undefined) console.log(tag, style); + else console.log(tag, style, data); + // coercion-ok: diagnostics must never break the interaction they observe + } catch {} +} + +/** Short, stable label for an element in a trace line. */ +export function traceEl(el: Element | null | undefined): string | null { + if (!el) return null; + const id = el.getAttribute?.("data-agent-native-node-id"); + const name = el.getAttribute?.("data-agent-native-layer-name"); + const primitive = el.getAttribute?.("data-an-primitive"); + const tag = el.tagName?.toLowerCase() ?? "?"; + return [ + tag, + primitive && `(${primitive})`, + name && `"${name}"`, + id && `#${id}`, + ] + .filter(Boolean) + .join(" "); +} + +/** Compact geometry for a trace line. */ +export function roundGeo(g: { + x: number; + y: number; + width: number; + height: number; +}): string { + return `${Math.round(g.x)},${Math.round(g.y)} ${Math.round(g.width)}x${Math.round(g.height)}`; +} diff --git a/templates/design/app/components/design/dnd-debug.ts b/templates/design/app/components/design/dnd-debug.ts index 491d9233e9..d353fa3268 100644 --- a/templates/design/app/components/design/dnd-debug.ts +++ b/templates/design/app/components/design/dnd-debug.ts @@ -15,7 +15,8 @@ declare global { export function dndHostLog(phase: string, data?: unknown): void { if (typeof window === "undefined") return; - if (!window.__DND_DEBUG) return; + if (window.__DND_DEBUG === false) return; + if (!window.__DND_DEBUG && !import.meta.env?.DEV) return; try { const tag = `%c[dnd:host:${phase}]`; const style = "color:#0ea5e9;font-weight:bold"; diff --git a/templates/design/app/components/design/edit-panel/position-layout-properties.tsx b/templates/design/app/components/design/edit-panel/position-layout-properties.tsx index d9404557f9..27236b7a74 100644 --- a/templates/design/app/components/design/edit-panel/position-layout-properties.tsx +++ b/templates/design/app/components/design/edit-panel/position-layout-properties.tsx @@ -367,6 +367,13 @@ export function PositionLayoutProperties({ const authoredTransform = authoredStyleValue(element, "transform"); const constraintsValue = deriveConstraintsValue(element); const [constraintsExpanded, setConstraintsExpanded] = useState(false); + // position:absolute/fixed takes a child out of the parent's flex flow, so it + // still anchors and keeps constraints. + const constraintsSuppressed = + element.isFlexChild && + !["absolute", "fixed"].includes( + (element.computedStyles?.position ?? "").toLowerCase(), + ); // 3D rotation/perspective progressive-disclosure expander — mirrors // CornerRadiusControl's showIndependentCorners pattern. Default-expanded // when the authored transform already has non-zero X/Y rotation or @@ -544,33 +551,40 @@ export function PositionLayoutProperties({ hoverRevealClassName="opacity-0 group-hover/field:opacity-100" />
- - - - - - {"Constraints" /* i18n-ignore design inspector tooltip */} - - + {/* Figma: constraints cannot apply to a child of an auto layout + frame — the parent's layout owns the position. An absolutely + positioned descendant is out of that flow and still anchors. */} + {constraintsSuppressed ? null : ( + + + + + + {"Constraints" /* i18n-ignore design inspector tooltip */} + + + )} - {constraintsExpanded ? ( + {constraintsExpanded && !constraintsSuppressed ? ( *,*::before,*::after{animation:none!important;animation-delay:0s!important;transition:none!important;caret-color:transparent!important;}html{width:${viewportWidth}px!important;height:${viewportHeight}px!important;overflow:hidden!important;}html,body{background:${BOARD_SURFACE_BACKGROUND}!important;background-color:${BOARD_SURFACE_BACKGROUND}!important;background-image:none!important;}body{margin:0!important;width:${width}px!important;height:${height}px!important;overflow:visible!important;transform:scale(${scale})!important;transform-origin:0 0!important;}body>[data-agent-native-node-id]{translate:${offsetX}px ${offsetY}px!important;}`; + const style = ``; if (/<\/head\s*>/i.test(renderHtml)) { return injectDocumentMarkup(renderHtml, style, { target: "head" }); } diff --git a/templates/design/app/components/design/multi-screen/frame-geometry.test.ts b/templates/design/app/components/design/multi-screen/frame-geometry.test.ts index 71989c2bd1..c4c115def7 100644 --- a/templates/design/app/components/design/multi-screen/frame-geometry.test.ts +++ b/templates/design/app/components/design/multi-screen/frame-geometry.test.ts @@ -40,6 +40,30 @@ describe("content-fit frame height", () => { expect(measured.naturalHeight).toBe(2600); }); + it("keeps a pinned height when content measures taller", () => { + const geo = getBreakpointFrameGeometry({ + widthPx: 390, + naturalAspect: 1, + primaryScale: 1, + contentHeightPx: 2600, + pinnedHeightPx: 844, + }); + expect(geo.naturalHeight).toBe(844); + }); + + it("keeps a pinned height below the device floor", () => { + // A pinned height is a user decision, so the floor must not raise it back + // up the way it does for a measured height. + const geo = getBreakpointFrameGeometry({ + widthPx: 390, + naturalAspect: 1, + primaryScale: 1, + contentHeightPx: 120, + pinnedHeightPx: 500, + }); + expect(geo.naturalHeight).toBe(500); + }); + it("never renders shorter than the device floor even when content is tiny", () => { const geo = getBreakpointFrameGeometry({ widthPx: 390, diff --git a/templates/design/app/components/design/multi-screen/frame-geometry.ts b/templates/design/app/components/design/multi-screen/frame-geometry.ts index 6a90fd8422..2db1a8c385 100644 --- a/templates/design/app/components/design/multi-screen/frame-geometry.ts +++ b/templates/design/app/components/design/multi-screen/frame-geometry.ts @@ -337,6 +337,9 @@ export function getBreakpointFrameGeometry(args: { /** Measured content height at this width; wins over the primary-aspect * projection, which clipped narrower frames (they reflow taller). */ contentHeightPx?: number; + /** A height the user set. Content measurement must never overwrite it, or + * the frame grows out from under them and 100vh content chases itself. */ + pinnedHeightPx?: number; }): { frameWidth: number; frameHeight: number; @@ -353,10 +356,16 @@ export function getBreakpointFrameGeometry(args: { : undefined; // Until the frame's own content is measured, use the pure aspect projection; // the device-viewport floor only applies once a real height is known. + const pinned = + args.pinnedHeightPx && args.pinnedHeightPx > 0 + ? Math.round(args.pinnedHeightPx) + : undefined; const naturalHeight = - measured !== undefined - ? Math.max(deviceViewportFloorForWidth(args.widthPx), measured) - : Math.round(args.widthPx * Math.max(0.01, args.naturalAspect)); + pinned !== undefined + ? pinned + : measured !== undefined + ? Math.max(deviceViewportFloorForWidth(args.widthPx), measured) + : Math.round(args.widthPx * Math.max(0.01, args.naturalAspect)); const frameWidth = Math.round(args.widthPx * scale); const frameHeight = Math.round(naturalHeight * scale); return { frameWidth, frameHeight, naturalHeight, scale }; diff --git a/templates/design/app/components/design/multi-screen/screen-content-cache.ts b/templates/design/app/components/design/multi-screen/screen-content-cache.ts index accd03355f..0cc53dbb3e 100644 --- a/templates/design/app/components/design/multi-screen/screen-content-cache.ts +++ b/templates/design/app/components/design/multi-screen/screen-content-cache.ts @@ -30,6 +30,7 @@ export function sameResolvedMetadata( a.title === b.title && a.width === b.width && a.height === b.height && + a.heightPinned === b.heightPinned && a.previewUrl === b.previewUrl ); } @@ -49,6 +50,7 @@ function sameScreenMetadataInput( a.title === b.title && a.width === b.width && a.height === b.height && + a.heightPinned === b.heightPinned && a.url === b.url && a.previewUrl === b.previewUrl && a.bridgeUrl === b.bridgeUrl && @@ -187,6 +189,8 @@ export function resolveScreenMetadata( title: metadata.title, width, height, + // A height the user dragged. Auto-fit must not grow past it. + heightPinned: metadata.heightPinned === true, previewUrl, }; } diff --git a/templates/design/app/components/design/multi-screen/types.ts b/templates/design/app/components/design/multi-screen/types.ts index 3bfd83b458..9a05a82820 100644 --- a/templates/design/app/components/design/multi-screen/types.ts +++ b/templates/design/app/components/design/multi-screen/types.ts @@ -32,6 +32,8 @@ export interface ScreenFile { updatedAt?: string; width?: number; height?: number; + /** A height the user dragged; auto-fit must not grow past it. */ + heightPinned?: boolean; url?: string; previewUrl?: string; bridgeUrl?: string; @@ -105,6 +107,7 @@ export interface ScreenMetadata { title?: string; width?: number; height?: number; + heightPinned?: boolean; url?: string; previewUrl?: string; bridgeUrl?: string; @@ -195,6 +198,8 @@ export interface MultiScreenCanvasProps { placement: "before" | "after" | "inside"; }) => void; onCreateScreenFrame?: (geometry: FrameGeometry) => void; + /** Whether the frame tool commits a top-level screen or a plain frame. */ + frameToolDraws?: "screen" | "frame"; onDeleteSelection?: (ids: string[]) => boolean | void; /** Return false to keep the arrow key: the host's real selection is an * element inside a screen, and this canvas still lists that screen in @@ -971,6 +976,8 @@ export interface ResolvedScreenMetadata { title?: string; width: number; height: number; + /** A height the user dragged; auto-fit must not grow past it. */ + heightPinned?: boolean; previewUrl?: string; } diff --git a/templates/design/app/hooks/useDesignHotkeys.ts b/templates/design/app/hooks/useDesignHotkeys.ts index 1b40f3ab65..3a9cd199a1 100644 --- a/templates/design/app/hooks/useDesignHotkeys.ts +++ b/templates/design/app/hooks/useDesignHotkeys.ts @@ -308,6 +308,9 @@ function isFocusableChromeTarget(target: EventTarget | null) { if (target === document.body || target === document.documentElement) { return false; } + // A layer row is a selection surface, not focus chrome — clicking one leaves + // focus on its button, and Figma traverses siblings from there. + if (target.closest("[data-layer-row-button]")) return false; return Boolean( target.closest( [ diff --git a/templates/design/app/i18n-data.ts b/templates/design/app/i18n-data.ts index f8600e40be..73dbb599ef 100644 --- a/templates/design/app/i18n-data.ts +++ b/templates/design/app/i18n-data.ts @@ -730,6 +730,7 @@ const enUS = { tools: { move: "Move", frame: "Frame", + screen: "Screen", rect: "Rectangle", line: "Line", arrow: "Arrow", diff --git a/templates/design/app/i18n/zh-TW.ts b/templates/design/app/i18n/zh-TW.ts index 9b58134d72..3a5d27f4b1 100644 --- a/templates/design/app/i18n/zh-TW.ts +++ b/templates/design/app/i18n/zh-TW.ts @@ -770,6 +770,7 @@ const messages = { tools: { move: "移動", frame: "畫框", + screen: "畫面", rect: "矩形", line: "線條", arrow: "箭頭", diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index 7bd258f72e..96cae63b41 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -148,6 +148,7 @@ import { IconBrush, IconPlus, IconLayoutGrid, + IconDevices, IconFrame, IconX, IconPin, @@ -242,6 +243,7 @@ import type { IframeImagePastePayload, } from "@/components/design/design-canvas/iframe-events"; import type { MotionTrackWire } from "@/components/design/design-canvas/motion-types"; +import { trace } from "@/components/design/design-trace"; import { DesignCanvas } from "@/components/design/DesignCanvas"; import { DesignEditorSkeleton } from "@/components/design/DesignEditorSkeleton"; import { @@ -1698,6 +1700,8 @@ function DesignBottomToolbar({ hasActiveFile, onMove, onFrame, + frameToolDraws, + onFrameToolDrawsChange, onShape, onText, onPen, @@ -1716,6 +1720,8 @@ function DesignBottomToolbar({ hasActiveFile: boolean; onMove: () => void; onFrame: () => void; + frameToolDraws: "screen" | "frame"; + onFrameToolDrawsChange: (value: "screen" | "frame") => void; onShape: (tool: ShapeTool) => void; onText: () => void; onPen: () => void; @@ -1884,8 +1890,16 @@ function DesignBottomToolbar({ { key: "frame", active: activeTool === "frame", - label: t("designEditor.tools.frame"), - icon: , + label: + frameToolDraws === "screen" + ? t("designEditor.tools.screen") + : t("designEditor.tools.frame"), + icon: + frameToolDraws === "screen" ? ( + + ) : ( + + ), onClick: onFrame, options: [ { @@ -1893,8 +1907,21 @@ function DesignBottomToolbar({ label: t("designEditor.tools.frame"), icon: , shortcut: "F", - active: activeTool === "frame", - onSelect: onFrame, + active: activeTool === "frame" && frameToolDraws === "frame", + onSelect: () => { + onFrameToolDrawsChange("frame"); + onFrame(); + }, + }, + { + key: "screen", + label: t("designEditor.tools.screen"), + icon: , + active: activeTool === "frame" && frameToolDraws === "screen", + onSelect: () => { + onFrameToolDrawsChange("screen"); + onFrame(); + }, }, ], }, @@ -2061,6 +2088,65 @@ function isSupersededSelectionEcho( * selections, and per-screen caches from one design can never leak into the * next design during client-side navigation. */ +/** + * A code-layer-derived ElementInfo carries authored inline styles and a zero + * rect, so the inspector shows 0 for anything the source does not state + * (hug sizing, in-flow position). Measure the live preview node instead. + */ +function withMeasuredGeometry( + info: ElementInfo, + screenId?: string, +): ElementInfo { + const rect = info.boundingRect; + if (rect && (rect.width > 0 || rect.height > 0)) return info; + if (typeof document === "undefined") return info; + const selector = info.runtimeSelector ?? info.selector; + if (!selector) return info; + // Selectors and stamped ids are per-screen, so an unscoped scan can measure + // identical markup on a different screen. + const owning = screenId + ? document.querySelector( + `iframe[data-design-preview-iframe][data-screen-iframe-id="${CSS.escape(screenId)}"]`, + ) + : null; + const frames = owning + ? [owning] + : Array.from( + document.querySelectorAll( + "iframe[data-design-preview-iframe]", + ), + ); + for (const frame of frames) { + let node: Element | null = null; + try { + node = frame.contentDocument?.querySelector(selector) ?? null; + } catch { + node = null; + } + if (!node) continue; + const box = node.getBoundingClientRect(); + if (box.width <= 0 && box.height <= 0) continue; + const computed = frame.contentWindow?.getComputedStyle(node); + return { + ...info, + boundingRect: { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + }, + computedStyles: computed + ? { + width: computed.width, + height: computed.height, + ...info.computedStyles, + } + : info.computedStyles, + }; + } + return info; +} + export default function DesignEditorRoute() { const { id } = useParams<{ id: string }>(); return ; @@ -2134,6 +2220,15 @@ function DesignEditor() { // Editor state const [mode, setMode] = useState("edit"); const [activeTool, setActiveTool] = useState("move"); + // The frame tool draws a top-level SCREEN or a plain FRAME container. Made + // explicit because deciding it from where the drag started is unguessable. + const [frameToolDraws, setFrameToolDraws] = useState<"screen" | "frame">( + "frame", + ); + // The persisted pin round-trips through the server, but content measurement + // fires as soon as the iframe loads. Without a synchronous local pin the + // frame grows to the device floor and snaps back once metadata lands. + const locallyPinnedHeightIdsRef = useRef>(new Set()); const activeToolRef = useRef(activeTool); useEffect(() => { activeToolRef.current = activeTool; @@ -2564,6 +2659,11 @@ function DesignEditor() { useEffect(() => { hasActiveSelectionRef.current = selectedElement !== null || selectedLayerIdsState.length > 0; + trace("select", "selection-changed", { + layers: selectedLayerIdsState, + element: selectedElement?.selector ?? null, + hasSelection: hasActiveSelectionRef.current, + }); }, [selectedElement, selectedLayerIdsState]); // Tracks the nodeId of the most recently created TEXT primitive across one // handleCreatePrimitive → handlePrimitiveCreated round-trip. Cleared after @@ -5333,6 +5433,11 @@ function DesignEditor() { layoutGroupId: stringValue("variantSetId"), width: numberValue("width"), height: numberValue("height"), + // Without this the pin never reaches the canvas and the content-fit + // pass grows a deliberately-sized screen straight back. + heightPinned: + metadata.heightPinned === true || + locallyPinnedHeightIdsRef.current.has(file.id), url: stringValue("url"), previewUrl: stringValue("previewUrl"), bridgeUrl: stringValue("bridgeUrl"), @@ -5632,7 +5737,10 @@ function DesignEditor() { const writeFrameGeometrySnapshot = useCallback( ( geometryById: CanvasFrameGeometryById, - options?: { syncViewportFrameIds?: string[] }, + options?: { + syncViewportFrameIds?: string[]; + pinHeightFrameIds?: string[]; + }, ) => { if (!id || !canEditDesignRef.current) return; const queuedSave = pendingFrameGeometrySaveRef.current; @@ -5669,6 +5777,7 @@ function DesignEditor() { nextGeometry: snapshot, designData: designDataJsonRef.current, syncViewportFrameIds: options?.syncViewportFrameIds, + pinHeightFrameIds: options?.pinHeightFrameIds, }), ]); if (dataOperations.length === 0) return; @@ -5795,7 +5904,13 @@ function DesignEditor() { writeFrameGeometrySnapshot( afterSnapshot, resizedFrameIds.length > 0 - ? { syncViewportFrameIds: resizedFrameIds } + ? (resizedFrameIds.forEach((frameId) => + locallyPinnedHeightIdsRef.current.add(frameId), + ), + { + syncViewportFrameIds: resizedFrameIds, + pinHeightFrameIds: resizedFrameIds, + }) : undefined, ); } @@ -7112,10 +7227,24 @@ function DesignEditor() { content, result, }); - writeFrameGeometrySnapshot({ - ...canvasFrameGeometryById, - [nextId]: nextGeometry, + locallyPinnedHeightIdsRef.current.add(nextId); + trace("screen", "pin-drawn-height", { + fileId: nextId, + height: nextGeometry.height, + why: "a drawn size is deliberate; without this the device floor and content-fit pass override it", }); + // The drawn height is a deliberate size, so pin it: otherwise + // the device floor and content-fit pass immediately override it. + writeFrameGeometrySnapshot( + { + ...canvasFrameGeometryById, + [nextId]: nextGeometry, + }, + { + syncViewportFrameIds: [nextId], + pinHeightFrameIds: [nextId], + }, + ); focusCreatedScreen(nextId, nextGeometry); recordFileCreationHistoryEntry({ filename, @@ -10144,6 +10273,15 @@ function DesignEditor() { clipboardMutation?: ClipboardContentMutationPublication; } = {}, ) => { + trace("persist", "write-file", { + file: activeFile?.filename ?? null, + bytes: nextContent.length, + blocked: !activeFile + ? "no active file" + : !canEditDesignRef.current + ? "read-only design" + : null, + }); if (!activeFile || !canEditDesignRef.current) return; const shouldRecordHistory = options.recordHistory !== false && !options.updatedAt; @@ -13443,6 +13581,10 @@ function DesignEditor() { originalStyles?: Record; } = {}, ) => { + trace("persist", "commit-styles", { + selector: typeof selector === "string" ? selector : null, + props: Object.keys(styles ?? {}), + }); if (!activeFile || !canEditDesign) return; // Cross-pipeline write race guard (see GlslShaderPanel.tsx's module doc // comment on withShaderWriteLock/waitForShaderWriteToSettle): a shader @@ -14871,10 +15013,19 @@ function DesignEditor() { ); const patch = applyVisualEdit(baseContent, { kind: "moveNode", - target: targetNode ? { nodeId: targetNode.id } : { selector }, + // Keep the bridge's stable source id on the fallback: resolving by + // selector alone fails for stamped nodes, and the resolver tries + // nodeId first before falling back to the selector anyway. + target: targetNode + ? { nodeId: targetNode.id } + : details?.sourceId + ? { nodeId: details.sourceId, selector } + : { selector }, anchor: anchorNode ? { nodeId: anchorNode.id } - : { selector: anchorSelector }, + : details?.anchorSourceId + ? { nodeId: details.anchorSourceId, selector: anchorSelector } + : { selector: anchorSelector }, placement, }); dndHostLog("persist:rewrite", { @@ -17144,6 +17295,10 @@ function DesignEditor() { ]); const handleDuplicateSelection = useCallback(() => { + trace("structure", "duplicate-selection", { + canEdit: canEditDesign, + selectedLayers: selectedLayerIdsState.length, + }); if (!canEditDesign) return; // U19: duplicate is a discrete one-shot action — see the matching note // in handlePasteSelection. @@ -17332,6 +17487,7 @@ function DesignEditor() { ]); const handleDeleteSelection = useCallback(() => { + trace("structure", "delete", { layers: selectedLayerIdsState.length }); if (!canEditDesign) return; // U19: delete is a discrete one-shot action — see the matching note in // handlePasteSelection. @@ -17985,6 +18141,7 @@ function DesignEditor() { // Wrap the current multi-layer selection into a new group container. const handleGroupSelection = useCallback(() => { + trace("structure", "group", { layers: selectedLayerIdsState.length }); if (!canEditDesign || !activeFile) return; const selectedRuntimeLayerIds = selectedLayerIdsState.filter( (layerId) => codeLayerOwnerByNodeIdRef.current.get(layerId)?.runtimeOnly, @@ -18310,13 +18467,22 @@ function DesignEditor() { // useDesignHotkeys' Alt+A/D/W/S/H/V bindings. const handleAlignSelection = useCallback( (edge: DesignHotkeyAlignEdge) => { + trace("structure", "align", { layers: selectedLayerIdsState.length }); if (!canEditDesign) return; // Overview, 2+ selected SCREENS: align each screen's frame geometry to // the selection's combined bounding box through the same // handleGeometryCommit path drags/nudges use — one undo step for the - // whole align. - if (viewModeRef.current === "overview") { + // whole align. A layer selection must fall through to the element path + // below instead, as Figma aligns whatever is selected. + if ( + viewModeRef.current === "overview" && + !overviewSelectionTargetsElement({ + selectedElement, + selectedLayerIds: selectedLayerIdsState, + fileIds: files.map((file) => file.id), + }) + ) { if (overviewSelectedScreenIds.length < 2) return; const before = getCanvasFrameGeometry(designDataJsonRef.current); const screenRects: AlignableRect[] = []; @@ -18429,6 +18595,7 @@ function DesignEditor() { handleGeometryCommit, overviewScreens, overviewSelectedScreenIds, + selectedLayerIdsState, rectFromCodeLayerNode, ], ); @@ -18872,9 +19039,22 @@ function DesignEditor() { // multiple screens remain unsupported because screens cannot safely be // nested without a first-class screen-container model. const handleAddAutoLayout = useCallback(() => { + trace("structure", "add-auto-layout", { + layers: selectedLayerIdsState.length, + view: viewModeRef.current, + }); if (!canEditDesign) return; - if (viewModeRef.current === "overview") { + // Overview handles whole screens; a layer selection must still reach the + // element path below, as Figma applies Shift+A to whatever is selected. + if ( + viewModeRef.current === "overview" && + !overviewSelectionTargetsElement({ + selectedElement, + selectedLayerIds: selectedLayerIdsState, + fileIds: files.map((file) => file.id), + }) + ) { if (overviewSelectedScreenIds.length === 0) return; if (overviewSelectedScreenIds.length !== 1 || selectedElement !== null) { toast(t("designEditor.toasts.autoLayoutScreensUnsupported")); @@ -19043,7 +19223,11 @@ function DesignEditor() { // zero-padding auto-layout frame; an existing container is converted. const soleNode = nodesById.get(nodeIds[0]!); if (!soleNode) return; - if (soleNode.children.length === 0) { + // An empty frame is a container, not a leaf: Figma converts it in place + // and only wraps a true leaf (text, shape) in a new auto-layout frame. + const soleIsFrame = + soleNode.dataAttributes["data-an-primitive"] === "frame"; + if (soleNode.children.length === 0 && !soleIsFrame) { const wrapped = applyVisualEdit(baseContent, { kind: "wrapNodes", targetIds: [soleNode.id], @@ -19095,7 +19279,8 @@ function DesignEditor() { const childNodes = soleNode.children .map((childId) => nodesById.get(childId)) .filter((node): node is CodeLayerNode => Boolean(node)); - if (childNodes.length === 0) return; + // An empty frame still takes auto layout in Figma, and + // inferAutoLayoutFromChildren already defaults that case to a column. const containerRect = rectFromCodeLayerNode(soleNode); const childRects = childNodes.map(rectFromCodeLayerNode); const inferred = inferAutoLayoutFromChildren(containerRect, childRects); @@ -19117,6 +19302,30 @@ function DesignEditor() { return; } let nextContent = patch.content; + // Figma reflows children when auto layout is enabled; opting one out is the + // explicit "ignore auto layout" toggle. wrapNodes already strips these on + // the multi-selection path — do the same for a single container. + // An empty value is rejected by isSafeStyleValue, so neutralise with CSS + // initial values rather than trying to remove the declarations. + const reflowResets: Array<[string, string]> = [ + ["position", "static"], + ["left", "auto"], + ["top", "auto"], + ["right", "auto"], + ["bottom", "auto"], + ]; + for (const childNode of childNodes) { + for (const [property, value] of reflowResets) { + const stripped = applyVisualEdit(nextContent, { + kind: "style", + target: { nodeId: childNode.id }, + property, + value, + }); + if (stripped.result.status === "applied") + nextContent = stripped.content; + } + } if (inferred.padding > 0) { const paddingPatch = applyVisualEdit(nextContent, { kind: "style", @@ -19177,6 +19386,7 @@ function DesignEditor() { // Figma does: ungrouping leaves the former children selected so the user // can immediately keep working with them. const handleUngroupSelection = useCallback(() => { + trace("structure", "ungroup", { layers: selectedLayerIdsState.length }); if (!canEditDesign || !activeFile) return; const selectedRuntimeLayerIds = selectedLayerIdsState.filter( (layerId) => codeLayerOwnerByNodeIdRef.current.get(layerId)?.runtimeOnly, @@ -19623,6 +19833,19 @@ function DesignEditor() { targetAnchorPlacement, targetDropMode, }); + trace("drop", "cross-screen-persist", { + from: sourceScreenId, + to: targetScreenId, + mode: targetDropMode, + placement: targetAnchorPlacement, + anchor: targetAnchorNodeId ?? targetAnchorSelector ?? null, + node: sourceNodeId ?? sourceSelector, + blocked: !canEditDesign + ? "read-only design" + : sourceScreenId === targetScreenId + ? "same screen — nothing to move" + : null, + }); if (!canEditDesign) return; if (sourceScreenId === targetScreenId) return; @@ -20815,6 +21038,7 @@ function DesignEditor() { const handleNudgeSelection = useCallback( (direction: "up" | "right" | "down" | "left", largeStep: boolean) => { + trace("structure", "nudge", { direction, largeStep }); if (!canEditDesign) return; const nudgeAmounts = editorPreferences.nudge; const freeTranslation = resolveNudgeIntent({ @@ -20870,11 +21094,17 @@ function DesignEditor() { return; } - if (!selectedElement?.selector) return; + // Selecting in the layers tree fills selectedLayerTargets before the + // bridge round-trip fills selectedElement, so keying off the latter + // alone silently drops the first nudge after every tree selection. + const nudgeTarget = selectedElement?.selector + ? selectedElement + : selectedLayerTargetsRef.current[0]?.elementInfo; + if (!nudgeTarget?.selector) return; const intent = resolveElementNudgeIntent({ content: activeFile ? getFreshActiveContent() : "", - selectedElement, + selectedElement: nudgeTarget, direction, largeStep, amounts: nudgeAmounts, @@ -20911,13 +21141,13 @@ function DesignEditor() { } hideSelectionChromeForNudge(); - const left = parseFloat(selectedElement.computedStyles.left || "0") || 0; - const top = parseFloat(selectedElement.computedStyles.top || "0") || 0; - commitVisualStyles(selectedElement.selector, { + const left = parseFloat(nudgeTarget.computedStyles.left || "0") || 0; + const top = parseFloat(nudgeTarget.computedStyles.top || "0") || 0; + commitVisualStyles(nudgeTarget.selector, { position: - selectedElement.computedStyles.position === "static" + nudgeTarget.computedStyles.position === "static" ? "relative" - : selectedElement.computedStyles.position || "relative", + : nudgeTarget.computedStyles.position || "relative", left: `${Math.round(left + intent.dx)}px`, top: `${Math.round(top + intent.dy)}px`, }); @@ -20946,6 +21176,7 @@ function DesignEditor() { // but undo/redo transactions use the UndoManager as origin so we must also // advance lastLocalContentRef and trigger the debounced save here. const handleUndo = useCallback(() => { + trace("history", "undo", {}); if (!canEditDesign) return; // U10: an in-progress drag hasn't been committed yet (onGeometryCommit / // the content update fires on drag END), so undoing mid-drag would pop a @@ -21615,6 +21846,7 @@ function DesignEditor() { ]); const handleRedo = useCallback(() => { + trace("history", "redo", {}); if (!canEditDesign) return; // U10: see the matching guard in handleUndo — don't redo into a document // state an in-progress, uncommitted drag is about to overwrite anyway. @@ -22392,6 +22624,7 @@ function DesignEditor() { ]); const handleZoomIn = useCallback(() => { + trace("tool", "zoom-in", {}); setZoom((z) => getNextZoomStepUp(z)); }, [setZoom]); @@ -23658,7 +23891,14 @@ function DesignEditor() { !(pendingQuestions && pendingQuestions.length > 0), shouldHandleEvent: shouldHandleEditorHotkey, onMoveTool: canEditDesign ? handleMoveTool : undefined, - onFrameTool: canEditDesign ? handleFrameTool : undefined, + // F always means Frame; without forcing the mode it would reuse whichever + // sub-tool the dropdown last selected. + onFrameTool: canEditDesign + ? () => { + setFrameToolDraws("frame"); + handleFrameTool(); + } + : undefined, onRectangleTool: canEditDesign ? handleRectTool : undefined, onLineTool: canEditDesign ? handleLineTool : undefined, onArrowTool: canEditDesign ? handleArrowTool : undefined, @@ -23688,7 +23928,13 @@ function DesignEditor() { onCopyProps: canEditDesign ? handleCopyProps : undefined, onPasteProps: canEditDesign ? handlePasteProps : undefined, onDuplicate: canEditDesign ? handleDuplicateSelection : undefined, - onDelete: canEditDesign ? handleDeleteSelection : undefined, + // Routes screen-vs-element itself; selecting a screen in the layers panel + // never reaches MultiScreenCanvas' capture-phase Delete. + onDelete: canEditDesign + ? () => { + handleDeleteOverviewSelection(selectedLayerIdsState); + } + : undefined, // L12: Cmd+R (and the context-menu Rename item, both routed through // useDesignHotkeys' onRename) previously always renamed the DESIGN // TITLE, even while a layer was selected — surprising when the user's @@ -26412,9 +26658,11 @@ function DesignEditor() { const selectedInspectorElements = useMemo( () => selectedLayerTargets.length > 0 - ? selectedLayerTargets.map((target) => target.elementInfo) + ? selectedLayerTargets.map((target) => + withMeasuredGeometry(target.elementInfo, target.fileId), + ) : selectedElement - ? [selectedElement] + ? [withMeasuredGeometry(selectedElement, activeFile?.id)] : [], [selectedElement, selectedLayerTargets], ); @@ -31207,6 +31455,8 @@ function DesignEditor() { hasActiveFile={Boolean(activeFile)} onMove={handleMoveTool} onFrame={handleFrameTool} + frameToolDraws={frameToolDraws} + onFrameToolDrawsChange={setFrameToolDraws} onShape={handleShapeTool} onText={handleTextTool} onPen={handlePenTool} @@ -31778,6 +32028,7 @@ function DesignEditor() { boardFileId ? handleBoardTextContentChange : undefined } onCreateScreenFrame={handleCreateScreenFrame} + frameToolDraws={frameToolDraws} onDeleteSelection={handleDeleteOverviewSelection} onNudgeSelection={handleOverviewNudgeSelection} onSelectionChange={handleOverviewScreenSelectionChange} diff --git a/templates/design/app/pages/design-editor/canvas-primitive-insert.test.ts b/templates/design/app/pages/design-editor/canvas-primitive-insert.test.ts index db2750e2e0..2d3fe37234 100644 --- a/templates/design/app/pages/design-editor/canvas-primitive-insert.test.ts +++ b/templates/design/app/pages/design-editor/canvas-primitive-insert.test.ts @@ -58,6 +58,135 @@ describe("appendCanvasPrimitiveToHtml on a URL-backed live screen", () => { ); expect(inserted).toContain('data-agent-native-node-id="rect-1"'); }); + + const textAt = (bodyStyle: string) => + appendCanvasPrimitiveToHtml( + `S`, + { + kind: "text", + nodeId: "t-1", + geometry: { x: 0, y: 0, width: 80, height: 24 }, + text: "Hello", + }, + ); + + it("gives drawn text a light fill on a dark screen, not currentColor", () => { + expect(textAt("background:#0b0f19")).toContain("color: #ffffff"); + }); + + it("leaves drawn text inheriting currentColor on a light screen", () => { + expect(textAt("background:#ffffff")).toContain("color: currentcolor"); + }); + + const withContainer = (kind: "frame" | "rectangle") => + appendCanvasPrimitiveToHtml( + appendCanvasPrimitiveToHtml(blankScreenHtml("S"), { + kind, + nodeId: "box", + geometry: { x: 20, y: 150, width: 280, height: 300 }, + }) ?? "", + { + kind: "text", + nodeId: "inner", + geometry: { x: 60, y: 260, width: 100, height: 20 }, + text: "Inside", + }, + ) ?? ""; + + it("nests a primitive drawn inside a frame's bounds into that frame", () => { + const html = withContainer("frame"); + const frameAt = html.indexOf('data-an-primitive="frame"'); + expect(html.indexOf('nodeId="inner"') === -1).toBe(true); + expect(html.indexOf('data-agent-native-node-id="inner"')).toBeGreaterThan( + frameAt, + ); + expect(html.indexOf('data-agent-native-node-id="inner"')).toBeLessThan( + html.indexOf("", frameAt) + "".length, + ); + }); + + it("nests an SVG primitive into a containing frame, like div primitives", () => { + const withFrame = + appendCanvasPrimitiveToHtml(blankScreenHtml("S"), { + kind: "frame", + nodeId: "outer", + geometry: { x: 20, y: 150, width: 280, height: 300 }, + }) ?? ""; + const html = + appendCanvasPrimitiveToHtml(withFrame, { + kind: "line", + nodeId: "seg", + geometry: { x: 60, y: 200, width: 100, height: 40 }, + points: [ + { x: 60, y: 200 }, + { x: 160, y: 240 }, + ], + }) ?? ""; + const frameAt = html.indexOf('data-an-primitive="frame"'); + const segAt = html.indexOf('data-agent-native-node-id="seg"'); + expect(segAt).toBeGreaterThan(frameAt); + expect(segAt).toBeLessThan(html.indexOf("", frameAt)); + }); + + it("resolves a nested frame against document coordinates", () => { + const outer = + appendCanvasPrimitiveToHtml(blankScreenHtml("S"), { + kind: "frame", + nodeId: "outer", + geometry: { x: 100, y: 100, width: 400, height: 400 }, + }) ?? ""; + // Nested frame's inline left/top are relative to `outer`, so a primitive + // at document 180,180 lands inside it only if offsets accumulate. + const nested = + appendCanvasPrimitiveToHtml(outer, { + kind: "frame", + nodeId: "inner", + geometry: { x: 150, y: 150, width: 200, height: 200 }, + }) ?? ""; + const html = + appendCanvasPrimitiveToHtml(nested, { + kind: "rectangle", + nodeId: "deep", + geometry: { x: 180, y: 180, width: 40, height: 40 }, + }) ?? ""; + const innerAt = html.indexOf('data-agent-native-node-id="inner"'); + const deepAt = html.indexOf('data-agent-native-node-id="deep"'); + expect( + deepAt, + "the rect must nest into the innermost containing frame", + ).toBeGreaterThan(innerAt); + }); + + it("gives text in a dark frame a light fill even on a light page", () => { + const withDarkFrame = + appendCanvasPrimitiveToHtml( + `S`, + { + kind: "frame", + nodeId: "dark", + geometry: { x: 20, y: 20, width: 300, height: 300 }, + fill: "#0b0f19", + }, + ) ?? ""; + const html = + appendCanvasPrimitiveToHtml(withDarkFrame, { + kind: "text", + nodeId: "label", + geometry: { x: 60, y: 60, width: 100, height: 20 }, + text: "Inside", + }) ?? ""; + expect(html).toContain("color: #ffffff"); + }); + + it("never nests into a rectangle — it is a shape, not a container", () => { + const html = withContainer("rectangle"); + const rectCloses = + html.indexOf("", html.indexOf('data-an-primitive="rectangle"')) + + "".length; + expect(html.indexOf('data-agent-native-node-id="inner"')).toBeGreaterThan( + rectCloses, + ); + }); }); describe("extractCanvasPrimitiveHtml", () => { diff --git a/templates/design/app/pages/design-editor/canvas-primitive-insert.ts b/templates/design/app/pages/design-editor/canvas-primitive-insert.ts index ba92182a41..779a7afe57 100644 --- a/templates/design/app/pages/design-editor/canvas-primitive-insert.ts +++ b/templates/design/app/pages/design-editor/canvas-primitive-insert.ts @@ -11,7 +11,10 @@ import { CANVAS_TEXT_DEFAULT_FONT_FAMILY, defaultCanvasTextColor, } from "./canvas-primitives"; -import { BOARD_TEXT_AUTO_COLOR_MARKER } from "./cross-screen-text-color"; +import { + BOARD_TEXT_AUTO_COLOR_MARKER, + destinationBackgroundIsLightForNode, +} from "./cross-screen-text-color"; import { escapeHtmlAttributeValue, escapeHtmlText } from "./dom-utils"; import { isStandaloneHttpUrl } from "./editor-state"; import type { DesignFile } from "./types"; @@ -183,6 +186,81 @@ export function polygonPointsForHtmlShape( * adaptAutoTextColorForCrossScreenNode. Any explicit user color edit must * remove this attribute so the text is never "helpfully" overridden again. */ + +/** Inline absolute rect, or null when the element is not absolutely placed. */ +function absoluteRect( + element: Element, +): { x: number; y: number; w: number; h: number } | null { + const style = (element as HTMLElement).style; + if (style.position !== "absolute") return null; + const read = (value: string) => { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; + }; + const x = read(style.left); + const y = read(style.top); + const w = read(style.width); + const h = read(style.height); + if (x === null || y === null || w === null || h === null) return null; + // Inline left/top are relative to the nearest positioned ancestor, so a + // nested frame must add its own offsets or it matches the wrong origin. + let originX = 0; + let originY = 0; + for ( + let ancestor = element.parentElement; + ancestor && ancestor.tagName.toLowerCase() !== "body"; + ancestor = ancestor.parentElement + ) { + const position = ancestor.style.position; + if ( + position !== "absolute" && + position !== "relative" && + position !== "fixed" + ) { + continue; + } + originX += read(ancestor.style.left) ?? 0; + originY += read(ancestor.style.top) ?? 0; + } + return { x: originX + x, y: originY + y, w, h }; +} + +/** + * Figma's frame is the container primitive and a rectangle is not, so only + * `data-an-primitive="frame"` adopts. Bounds come from inline geometry + * because this document is parsed, never laid out. + */ +function deepestFrameContaining( + root: Element, + x: number, + y: number, + w: number, + h: number, +): { element: Element; x: number; y: number } | null { + const contained = Array.from( + root.querySelectorAll('[data-an-primitive="frame"]'), + ) + .map((element) => ({ element, rect: absoluteRect(element) })) + .filter( + ( + candidate, + ): candidate is { + element: Element; + rect: { x: number; y: number; w: number; h: number }; + } => + candidate.rect !== null && + x >= candidate.rect.x && + y >= candidate.rect.y && + x + w <= candidate.rect.x + candidate.rect.w && + y + h <= candidate.rect.y + candidate.rect.h, + ) + .sort((a, b) => a.rect.w * a.rect.h - b.rect.w * b.rect.h); + const best = contained[0]; + return best + ? { element: best.element, x: best.rect.x, y: best.rect.y } + : null; +} + export function appendCanvasPrimitiveToHtml( content: string, primitive: CanvasPrimitiveInsert, @@ -208,6 +286,10 @@ export function appendCanvasPrimitiveToHtml( const height = Math.max(1, Math.round(geometry.height)); const nodeId = primitive.nodeId ?? uniqueLayerId(primitive.kind); const layerName = primitiveLayerName(primitive); + // Resolved once so every primitive kind nests identically, and so text can + // pick a fill that is legible against its actual container. + const host = deepestFrameContaining(doc.body, left, top, width, height); + const hostOrBody: Element = host?.element ?? doc.body; if ( primitive.kind === "path" || @@ -327,7 +409,7 @@ export function appendCanvasPrimitiveToHtml( .join(";"), ); svg.appendChild(path); - doc.body.appendChild(svg); + hostOrBody.appendChild(svg); return `\n${doc.documentElement.outerHTML}`; } @@ -370,7 +452,7 @@ export function appendCanvasPrimitiveToHtml( .join(";"), ); svg.appendChild(polygon); - doc.body.appendChild(svg); + hostOrBody.appendChild(svg); return `\n${doc.documentElement.outerHTML}`; } @@ -428,16 +510,17 @@ export function appendCanvasPrimitiveToHtml( // not centered — match that instead of centering the text block. element.style.alignItems = "flex-start"; } - // Board (dark infinite-canvas) text needs an explicit default fill — - // "currentColor" inherits the unstyled document's black body text, - // invisible on the dark canvas background. The board surface is - // always dark regardless of the editor chrome theme, so this keys off - // the target surface only (see defaultCanvasTextColor). Screens keep - // "currentColor" so text dropped into an existing (often light) - // screen still inherits its surrounding styles/theme as before. + // "currentColor" inherits the unstyled document's black body text, so + // it is invisible on any dark surface — the always-dark board, and + // equally a screen whose own background is dark. Light screens keep + // "currentColor" so text still inherits their theme. + // Measure the frame the text actually lands in: a dark frame on a light + // page would otherwise keep currentColor and render invisible. + const autoTextNeedsLightFill = + options?.isBoardTarget === true || + !destinationBackgroundIsLightForNode(hostOrBody); const resolvedTextColor = - primitive.fill ?? - defaultCanvasTextColor(options?.isBoardTarget === true); + primitive.fill ?? defaultCanvasTextColor(autoTextNeedsLightFill); element.style.color = resolvedTextColor; // Stamp the auto-color marker whenever the color came from the // default (no explicit primitive.fill) rather than a user-chosen @@ -477,7 +560,11 @@ export function appendCanvasPrimitiveToHtml( element.style.borderRadius = canonical.borderRadius; } - doc.body.appendChild(element); + if (host) { + element.style.left = `${left - host.x}px`; + element.style.top = `${top - host.y}px`; + } + hostOrBody.appendChild(element); return `\n${doc.documentElement.outerHTML}`; } catch { return null; diff --git a/templates/design/app/pages/design-editor/cross-screen-text-color.ts b/templates/design/app/pages/design-editor/cross-screen-text-color.ts index 346a4a1069..43f0800c73 100644 --- a/templates/design/app/pages/design-editor/cross-screen-text-color.ts +++ b/templates/design/app/pages/design-editor/cross-screen-text-color.ts @@ -209,7 +209,7 @@ function collectDestinationBackgroundSignals( * dark-class-hint chain when no live document is available (e.g. the * destination screen isn't currently mounted). */ -function destinationBackgroundIsLightForNode( +export function destinationBackgroundIsLightForNode( element: Element, liveDoc?: Document | null, ): boolean { diff --git a/templates/design/app/pages/design-editor/data-operations.ts b/templates/design/app/pages/design-editor/data-operations.ts index 4cd294880d..af2e0139de 100644 --- a/templates/design/app/pages/design-editor/data-operations.ts +++ b/templates/design/app/pages/design-editor/data-operations.ts @@ -114,6 +114,8 @@ export function buildFrameGeometryDataOperations(args: { nextGeometry: CanvasFrameGeometryById; designData: Record; syncViewportFrameIds?: readonly string[]; + /** Frames whose height the user just dragged. */ + pinHeightFrameIds?: readonly string[]; }): DesignDataOperation[] { const operations: DesignDataOperation[] = []; const frameIds = new Set([ @@ -156,6 +158,18 @@ export function buildFrameGeometryDataOperations(args: { value: viewport.height, }); } + // A resize is a deliberate height. Without this the content-fit pass in + // MultiScreenCanvas grows the frame straight back past the drag. + if ( + args.pinHeightFrameIds?.includes(frameId) && + !metadataEntry.heightPinned + ) { + operations.push({ + op: "set", + path: ["screenMetadata", frameId, "heightPinned"], + value: true, + }); + } const localhostEntry = recordValue(localhostScreens, frameId); if (Object.keys(localhostEntry).length === 0) continue; diff --git a/templates/design/changelog/2026-08-10-marquee-selecting-layers-far-down-a-tall-screen-now-selects-.md b/templates/design/changelog/2026-08-10-marquee-selecting-layers-far-down-a-tall-screen-now-selects-.md new file mode 100644 index 0000000000..152005eae7 --- /dev/null +++ b/templates/design/changelog/2026-08-10-marquee-selecting-layers-far-down-a-tall-screen-now-selects-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-10 +--- + +Marquee-selecting layers far down a tall screen now selects the layers you dragged over diff --git a/templates/design/changelog/2026-08-10-the-frame-tool-draws-a-plain-frame-by-default-with-screen-av.md b/templates/design/changelog/2026-08-10-the-frame-tool-draws-a-plain-frame-by-default-with-screen-av.md new file mode 100644 index 0000000000..328eea3e33 --- /dev/null +++ b/templates/design/changelog/2026-08-10-the-frame-tool-draws-a-plain-frame-by-default-with-screen-av.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-08-10 +--- + +The frame tool draws a plain frame by default, with Screen available in its dropdown diff --git a/templates/design/e2e/canvas-invariants.spec.ts b/templates/design/e2e/canvas-invariants.spec.ts new file mode 100644 index 0000000000..3de651534a --- /dev/null +++ b/templates/design/e2e/canvas-invariants.spec.ts @@ -0,0 +1,1101 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +/** + * Invariants a direct-manipulation canvas must hold: the inspector, the + * document and the rendered pixels all describe the same element. Each test + * asserts the correct behaviour, so a failure is the bug report. + */ + +const PAGE_W = 1440; +const PAGE_H = 900; +const MOD = process.platform === "darwin" ? "Meta" : "Control"; + +/** Mirrors the reported screenshot: an auto-layout section with three children. */ +const INTRO_PAGE = ` + + Playbook + +
+

Title

+

Short, one line sub header

+

A few sentences describing the purpose of the document.

+
+
+ +`; + +/** An in-flow child of an auto-layout page: no authored left/top at all. */ +const FLOW_PAGE = ` + + Playbook + +
+
+
+

Title

+

Short, one line sub header

+
+
+ +`; + +const ABSOLUTE_CHILDREN_PAGE = ` + + Overlap + +
+

Title

+

Short, one line sub header

+

A few sentences describing the purpose.

+
+ +`; + +const BLANK_PAGE = ` + + Blank + +`; + +interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +let baseURL = ""; +let pageErrors: string[] = []; + +async function postAction( + page: Page, + name: string, + input: Record, +) { + const res = await page.request.post( + `${baseURL}/_agent-native/actions/${name}`, + { + data: input, + headers: { "Content-Type": "application/json" }, + }, + ); + if (!res.ok()) { + throw new Error( + `${name}: ${res.status()} ${(await res.text()).slice(0, 200)}`, + ); + } + return res.json(); +} + +async function newDesign(page: Page, content: string): Promise { + const created = await postAction(page, "create-design", { + title: "Canvas invariants", + projectType: "prototype", + }); + const id = created?.id ?? created?.data?.id; + if (!id) throw new Error("create-design returned no id"); + await postAction(page, "create-file", { + designId: id, + filename: "index.html", + content, + fileType: "html", + }); + return id; +} + +async function indexHtml(page: Page, designId: string): Promise { + const result = await page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${designId}`) + .then((r) => r.json()); + return ( + (result.files ?? []).find((f: any) => f.filename === "index.html") + ?.content ?? "" + ); +} + +function toolbar(page: Page): Locator { + return page.locator("[data-design-bottom-toolbar]"); +} + +function layersTree(page: Page): Locator { + return page.getByRole("tree", { name: "Layers" }); +} + +function layerRow(page: Page, name: string): Locator { + return layersTree(page) + .getByRole("treeitem") + .filter({ hasText: name }) + .first(); +} + +function inFrame(page: Page, selector: string): Locator { + return page + .frameLocator("iframe[data-design-preview-iframe]") + .first() + .locator(selector); +} + +function node(page: Page, id: string): Locator { + return inFrame(page, `[data-agent-native-node-id="${id}"]`); +} + +async function openEditor(page: Page, designId: string): Promise { + await page.goto(`${baseURL}/design/${designId}?view=overview`, { + waitUntil: "domcontentloaded", + }); + await page.waitForURL( + (url) => + url.pathname === `/design/${designId}` && + url.searchParams.get("view") === "overview" && + url.searchParams.has("screen") && + url.searchParams.has("zoom"), + { timeout: 45_000 }, + ); + await toolbar(page) + .locator('button[aria-label="Move"]') + .waitFor({ timeout: 45_000 }); + await page + .locator("iframe[data-design-preview-iframe]") + .first() + .waitFor({ timeout: 30_000 }); + await page.waitForTimeout(2500); + await expandAllLayers(page); +} + +/** Nested rows only appear once every ancestor is expanded. */ +async function expandAllLayers(page: Page): Promise { + for (let pass = 0; pass < 6; pass += 1) { + const toggles = page.getByRole("button", { name: "Expand layer" }); + const count = await toggles.count(); + if (count === 0) break; + for (let i = 0; i < count; i += 1) { + await toggles.nth(0).click(); + await page.waitForTimeout(200); + } + await page.waitForTimeout(400); + } + await page.waitForTimeout(600); +} + +/** Prefer the real aria-label; a text-then-next-input walk lands on padding. */ +async function inspectorField(page: Page, label: string): Promise { + const aria = + label === "W" || label === "H" + ? page.locator(`input[aria-label="${label} size in pixels"]`) + : page.locator(`input[aria-label="${label}"]`); + if ((await aria.count()) > 0) { + const value = (await aria.first().inputValue()).trim(); + if (value !== "") return value; + const placeholder = await aria.first().getAttribute("placeholder"); + return (placeholder ?? "").trim(); + } + const input = page + .getByText(label, { exact: true }) + .first() + .locator("xpath=following::input[1]"); + if ((await input.count()) === 0) return ""; + return (await input.inputValue()).trim(); +} + +async function setInspectorField( + page: Page, + label: string, + value: string, +): Promise { + const input = page + .getByText(label, { exact: true }) + .first() + .locator("xpath=following::input[1]"); + await input.fill(value); + await input.press("Enter"); + await page.waitForTimeout(1800); +} + +function num(value: string): number { + const m = /(-?[\d.]+)/.exec(value); + return m ? Number(m[1]) : NaN; +} + +/** The element's real box inside the preview document. */ +async function renderedRect(page: Page, id: string): Promise { + return node(page, id).evaluate((el) => { + const r = el.getBoundingClientRect(); + return { + left: Math.round(r.left), + top: Math.round(r.top), + width: Math.round(r.width), + height: Math.round(r.height), + }; + }); +} + +function styleOf(html: string, id: string): string { + return ( + new RegExp( + `data-agent-native-node-id="${id}"[^>]*?style="([^"]*)"`, + "i", + ).exec(html)?.[1] ?? "" + ); +} + +function styleNum(style: string, prop: string): number { + const m = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*(-?[\\d.]+)px`, "i").exec( + style, + ); + return m ? Number(m[1]) : NaN; +} + +async function selectOnCanvas(page: Page, id: string): Promise { + const box = await node(page, id).boundingBox(); + if (!box) throw new Error(`no hit box for ${id}`); + await page.mouse.click( + box.x + Math.min(20, box.width / 2), + box.y + box.height / 2, + ); + await page.waitForTimeout(1800); +} + +/** The screen's own content viewport — never assume; it is not the page size. */ +async function contentSize(page: Page): Promise<{ w: number; h: number }> { + return page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => ({ + w: document.documentElement.clientWidth, + h: document.documentElement.clientHeight, + })); +} + +async function toScreenPoint(page: Page, x: number, y: number) { + const card = await page.locator("[data-screen-card]").first().boundingBox(); + if (!card) throw new Error("no screen card"); + const size = await contentSize(page); + return { + x: card.x + (x / size.w) * card.width, + y: card.y + (y / size.h) * card.height, + }; +} + +async function drawWith(page: Page, tool: string, rect: Rect): Promise { + await toolbar(page).locator(`button[aria-label="${tool}"]`).click(); + await page.waitForTimeout(250); + const a = await toScreenPoint(page, rect.left, rect.top); + const b = await toScreenPoint( + page, + rect.left + rect.width, + rect.top + rect.height, + ); + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + await page.mouse.move(b.x, b.y, { steps: 14 }); + await page.waitForTimeout(200); + await page.mouse.up(); + await page.waitForTimeout(1600); +} + +async function toasts(page: Page): Promise { + return page + .locator("[data-sonner-toast], [role='alert']") + .allTextContents() + .then((t) => t.map((s) => s.trim()).filter(Boolean)) + .catch(() => []); +} + +test.use({ viewport: { width: 1600, height: 1000 } }); + +test.beforeEach(async ({ page }, testInfo) => { + baseURL = + (testInfo.project.use.baseURL as string | undefined) ?? + process.env.E2E_BASE_URL ?? + `http://127.0.0.1:${process.env.E2E_PORT ?? 9333}`; + pageErrors = []; + page.on("pageerror", (e) => + pageErrors.push(`${e.name}: ${e.message}`.slice(0, 160)), + ); +}); + +test.describe("inspector reports the truth", () => { + test("X/Y match the element's real position, not 0,0", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Intro").click(); + await page.waitForTimeout(1800); + + const authored = styleOf(await indexHtml(page, id), "intro"); + const wantX = styleNum(authored, "left"); + const wantY = styleNum(authored, "top"); + const x = num(await inspectorField(page, "X")); + const y = num(await inspectorField(page, "Y")); + expect( + [x, y], + `Intro is at left:${wantX}px top:${wantY}px in the document, but the inspector ` + + `shows X=${x} Y=${y}.`, + ).toEqual([wantX, wantY]); + }); + + test("W/H are non-zero for an element that renders with a size", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Intro").click(); + await page.waitForTimeout(1800); + + const rendered = await renderedRect(page, "intro"); + const w = num(await inspectorField(page, "W")); + const h = num(await inspectorField(page, "H")); + expect( + rendered.width > 0 && rendered.height > 0, + "fixture problem: Intro should render with a size", + ).toBe(true); + expect( + [w > 0, h > 0], + `Intro renders ${rendered.width}x${rendered.height} but the inspector shows W=${w} H=${h}.`, + ).toEqual([true, true]); + }); + + test("W/H match the rendered size within a pixel", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + const row = layerRow(page, "Plain Box"); + await expect(row).toBeVisible({ timeout: 15_000 }); + await row.click(); + await page.waitForTimeout(1800); + + const rendered = await renderedRect(page, "plain-box"); + const width = num(await inspectorField(page, "W")); + const height = num(await inspectorField(page, "H")); + expect(Math.abs(width - rendered.width)).toBeLessThanOrEqual(1); + expect(Math.abs(height - rendered.height)).toBeLessThanOrEqual(1); + }); + + test("a hug-sized auto-layout container reports its measured width", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Intro").click(); + await page.waitForTimeout(1800); + + const rendered = await renderedRect(page, "intro"); + const w = num(await inspectorField(page, "W")); + expect( + w, + `Hug sizing reported W=${w} for a container measuring ${rendered.width}px.`, + ).toBeCloseTo(rendered.width, -1); + }); + + test("an in-flow element reports its real position, not 0,0", async ({ + page, + }) => { + const id = await newDesign(page, FLOW_PAGE); + await openEditor(page, id); + await layerRow(page, "Intro").click(); + await page.waitForTimeout(1800); + + const rendered = await renderedRect(page, "flow-intro"); + const wrap = await renderedRect(page, "page-wrap"); + const x = num(await inspectorField(page, "X")); + const y = num(await inspectorField(page, "Y")); + expect( + [x, y], + `Intro is laid out by its auto-layout parent at (${rendered.left - wrap.left}, ` + + `${rendered.top - wrap.top}) relative to the page wrapper, but the inspector ` + + `reports X=${x} Y=${y}. NOT a Figma-parity claim (Figma has no in-flow concept); the claim is that an inspector must not report 0 for an element that is demonstrably positioned.`, + ).not.toEqual([0, 0]); + }); + + test("an in-flow element reports a non-zero size", async ({ page }) => { + const id = await newDesign(page, FLOW_PAGE); + await openEditor(page, id); + await layerRow(page, "Intro").click(); + await page.waitForTimeout(1800); + + const rendered = await renderedRect(page, "flow-intro"); + const w = num(await inspectorField(page, "W")); + const h = num(await inspectorField(page, "H")); + expect( + [w > 0, h > 0], + `Intro renders ${rendered.width}x${rendered.height}; inspector shows W=${w} H=${h}.`, + ).toEqual([true, true]); + }); + + test("selecting on canvas and in the tree give the same geometry", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1600); + const viaTree = [ + await inspectorField(page, "X"), + await inspectorField(page, "Y"), + ]; + + await openEditor(page, id); + const box = await node(page, "plain-box").boundingBox(); + await page.keyboard.down(MOD === "Meta" ? "Meta" : "Control"); + await page.mouse.click(box!.x + 10, box!.y + box!.height / 2); + await page.keyboard.up(MOD === "Meta" ? "Meta" : "Control"); + await page.waitForTimeout(1800); + const viaCanvas = [ + await inspectorField(page, "X"), + await inspectorField(page, "Y"), + ]; + + expect(viaCanvas, `tree said ${viaTree}, canvas said ${viaCanvas}`).toEqual( + viaTree, + ); + }); + + test("a child of an auto-layout parent still reports real geometry", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Title").click(); + await page.waitForTimeout(1800); + + const rendered = await renderedRect(page, "intro-title"); + const w = num(await inspectorField(page, "W")); + const h = num(await inspectorField(page, "H")); + expect( + [w > 0, h > 0], + `Title renders ${rendered.width}x${rendered.height} but the inspector shows W=${w} H=${h}.`, + ).toEqual([true, true]); + }); + + test("setting X moves the element by exactly that amount", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1600); + + await setInspectorField(page, "X", "300"); + expect( + styleNum(styleOf(await indexHtml(page, id), "plain-box"), "left"), + ).toBe(300); + }); + + test("setting Y moves the element by exactly that amount", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1600); + + await setInspectorField(page, "Y", "400"); + expect( + styleNum(styleOf(await indexHtml(page, id), "plain-box"), "top"), + ).toBe(400); + }); + + test("re-entering the value already shown does not move the element", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1600); + + const shown = await inspectorField(page, "X"); + const before = styleNum( + styleOf(await indexHtml(page, id), "plain-box"), + "left", + ); + await setInspectorField(page, "X", String(num(shown))); + const after = styleNum( + styleOf(await indexHtml(page, id), "plain-box"), + "left", + ); + expect( + after, + `inspector showed X=${shown}; typing it back moved left from ${before} to ${after}.`, + ).toBe(before); + }); + + test("changing width does not change the element's position", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1600); + + const before = styleOf(await indexHtml(page, id), "plain-box"); + await setInspectorField(page, "W", "500"); + const after = styleOf(await indexHtml(page, id), "plain-box"); + expect([styleNum(after, "left"), styleNum(after, "top")]).toEqual([ + styleNum(before, "left"), + styleNum(before, "top"), + ]); + }); +}); + +// ── Auto layout must actually lay out ───────────────────────────────────── + +test.describe("auto layout", () => { + test("children of a column do not overlap each other", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + + const boxes = await Promise.all( + ["intro-title", "intro-sub", "intro-body"].map((n) => + renderedRect(page, n), + ), + ); + const overlaps: string[] = []; + for (let i = 1; i < boxes.length; i += 1) { + if (boxes[i].top < boxes[i - 1].top + boxes[i - 1].height) { + overlaps.push( + `child ${i} starts at y=${boxes[i].top} before child ${i - 1} ends at ` + + `y=${boxes[i - 1].top + boxes[i - 1].height}`, + ); + } + } + expect( + overlaps, + `Auto-layout column children are painted on top of each other: ${overlaps.join("; ")}`, + ).toEqual([]); + }); + + test("every child stays inside its auto-layout parent", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + + const parent = await renderedRect(page, "intro"); + const escaped: string[] = []; + for (const child of ["intro-title", "intro-sub", "intro-body"]) { + const r = await renderedRect(page, child); + if (r.top < parent.top - 1 || r.left < parent.left - 1) { + escaped.push( + `${child} at (${r.left},${r.top}) vs parent (${parent.left},${parent.top})`, + ); + } + } + expect( + escaped, + `children escaped their container: ${escaped.join("; ")}`, + ).toEqual([]); + }); + + test("the gap declared on the container is honoured between children", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + + const a = await renderedRect(page, "intro-title"); + const b = await renderedRect(page, "intro-sub"); + expect( + b.top - (a.top + a.height), + `container declares gap:16px; measured ${b.top - (a.top + a.height)}px between the first two children.`, + ).toBeCloseTo(16, -1); + }); + + test("absolutely-positioned children are reflowed, not left stacked", async ({ + page, + }) => { + const id = await newDesign(page, ABSOLUTE_CHILDREN_PAGE); + await openEditor(page, id); + await layerRow(page, "Intro").click(); + await page.waitForTimeout(1200); + await page.keyboard.press("Shift+A"); + await page.waitForTimeout(2500); + + const boxes = await Promise.all( + ["abs-title", "abs-sub", "abs-body"].map((n) => renderedRect(page, n)), + ); + // A row layout legitimately shares tops, so test for overlap, not for + // differing tops. + const overlapping = boxes.filter((b, i) => + boxes.some( + (other, j) => + j < i && + b.left < other.left + other.width && + b.left + b.width > other.left && + b.top < other.top + other.height && + b.top + b.height > other.top, + ), + ); + expect( + overlapping, + `Enabling auto layout must reflow the children so they no longer overlap, ` + + `as Figma does. Boxes: ${JSON.stringify(boxes)}. Opting a child out is ` + + `the explicit "ignore auto layout" toggle, not the default.`, + ).toEqual([]); + }); + + test("Shift+A on a container preserves its children's order", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + const before = await indexHtml(page, id); + const orderBefore = ["intro-title", "intro-sub", "intro-body"].map((n) => + before.indexOf(n), + ); + + await layerRow(page, "Intro").click(); + await page.waitForTimeout(1200); + await page.keyboard.press("Shift+A"); + await page.waitForTimeout(2500); + + const after = await indexHtml(page, id); + const orderAfter = ["intro-title", "intro-sub", "intro-body"].map((n) => + after.indexOf(n), + ); + expect( + orderAfter.every((v) => v >= 0), + "a child vanished from the document", + ).toBe(true); + expect( + orderAfter[0] < orderAfter[1] && orderAfter[1] < orderAfter[2], + `child order changed: ${JSON.stringify(orderBefore)} → ${JSON.stringify(orderAfter)}`, + ).toBe(true); + }); +}); + +// ── What you draw is what you get ───────────────────────────────────────── + +test.describe("drawing fidelity", () => { + for (const tool of ["Rectangle", "Frame"]) { + test(`${tool} commits the exact rect you dragged`, async ({ page }) => { + const id = await newDesign(page, BLANK_PAGE); + await openEditor(page, id); + const want: Rect = { left: 40, top: 120, width: 160, height: 200 }; + await drawWith(page, tool, want); + + const html = await indexHtml(page, id); + const kind = tool.toLowerCase(); + const style = + new RegExp( + `data-an-primitive="${kind}"[^>]*?style="([^"]*)"`, + "i", + ).exec(html)?.[1] ?? ""; + expect(style, `${tool} committed nothing`).not.toBe(""); + const actual = [ + styleNum(style, "left"), + styleNum(style, "top"), + styleNum(style, "width"), + styleNum(style, "height"), + ]; + const expected = [want.left, want.top, want.width, want.height]; + for (let index = 0; index < actual.length; index += 1) { + expect(Math.abs(actual[index]! - expected[index]!)).toBeLessThanOrEqual( + 1, + ); + } + }); + } + + test("a shape drawn at the page origin lands at 0,0", async ({ page }) => { + const id = await newDesign(page, BLANK_PAGE); + await openEditor(page, id); + await drawWith(page, "Rectangle", { + left: 0, + top: 0, + width: 200, + height: 120, + }); + + const style = + /data-an-primitive="rectangle"[^>]*?style="([^"]*)"/i.exec( + await indexHtml(page, id), + )?.[1] ?? ""; + expect( + style, + "nothing committed when drawing from the page origin", + ).not.toBe(""); + expect([styleNum(style, "left"), styleNum(style, "top")]).toEqual([ + expect.closeTo(0, -1), + expect.closeTo(0, -1), + ]); + }); + + test("the same drag at a different zoom produces the same rect", async ({ + page, + }) => { + const id = await newDesign(page, BLANK_PAGE); + await openEditor(page, id); + const want: Rect = { left: 40, top: 200, width: 160, height: 150 }; + await drawWith(page, "Rectangle", want); + const first = + /data-an-primitive="rectangle"[^>]*?style="([^"]*)"/i.exec( + await indexHtml(page, id), + )?.[1] ?? ""; + + const id2 = await newDesign(page, BLANK_PAGE); + await openEditor(page, id2); + await page.keyboard.press(`${MOD}+-`); + await page.waitForTimeout(1200); + await drawWith(page, "Rectangle", want); + const second = + /data-an-primitive="rectangle"[^>]*?style="([^"]*)"/i.exec( + await indexHtml(page, id2), + )?.[1] ?? ""; + + expect( + [styleNum(second, "width"), styleNum(second, "height")], + `zoomed out then drew the same rect: ${styleNum(first, "width")}x${styleNum(first, "height")} ` + + `vs ${styleNum(second, "width")}x${styleNum(second, "height")}`, + ).toEqual([ + expect.closeTo(styleNum(first, "width"), -1), + expect.closeTo(styleNum(first, "height"), -1), + ]); + }); + + test("a drawn shape primitive is visible — it has a fill or stroke", async ({ + page, + }) => { + const id = await newDesign(page, BLANK_PAGE); + await openEditor(page, id); + await drawWith(page, "Rectangle", { + left: 40, + top: 100, + width: 200, + height: 200, + }); + + const paint = await inFrame(page, '[data-an-primitive="rectangle"]') + .first() + .evaluate((el) => { + const cs = getComputedStyle(el); + return { bg: cs.backgroundColor, border: cs.borderTopWidth }; + }) + .catch(() => null); + expect(paint, "no rectangle rendered in the preview").not.toBeNull(); + expect( + /rgba\(0, 0, 0, 0\)/.test(paint!.bg) && paint!.border === "0px", + `a shape primitive must paint something; got background ${paint!.bg} and ` + + `border ${paint!.border}`, + ).toBe(false); + }); + + test("a drawn frame stays unstyled and is shown by selection chrome instead", async ({ + page, + }) => { + const id = await newDesign(page, BLANK_PAGE); + await openEditor(page, id); + await drawWith(page, "Frame", { + left: 40, + top: 100, + width: 200, + height: 200, + }); + + // A frame commits as unstyled structure on purpose (see the frame branch + // in canvas-primitive-insert.ts); baking a tint into the design's real + // HTML would be styling pollution. Its bounds come from selection chrome. + const state = await inFrame(page, "body") + .first() + .evaluate(() => { + const el = document.querySelector( + '[data-an-primitive="frame"]', + ) as HTMLElement | null; + if (!el) return null; + const selection = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ) as HTMLElement | null; + const rect = el.getBoundingClientRect(); + const chrome = selection?.getBoundingClientRect(); + return { + inlineBackground: el.style.background, + selectionTracksFrame: chrome + ? Math.abs(chrome.width - rect.width) < 4 && + Math.abs(chrome.left - rect.left) < 4 + : false, + }; + }); + expect(state, "no frame rendered in the preview").not.toBeNull(); + expect( + state!.inlineBackground, + "a committed frame must not bake in a fill", + ).toBe(""); + expect( + state!.selectionTracksFrame, + "an unstyled frame is only visible via selection chrome, so it must be " + + "selected and outlined the moment it is drawn", + ).toBe(true); + }); +}); + +// ── Moving things ───────────────────────────────────────────────────────── + +test.describe("moving", () => { + test("a canvas drag moves the element by the drag delta", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await selectOnCanvas(page, "plain-box"); + + const before = styleOf(await indexHtml(page, id), "plain-box"); + const box = await node(page, "plain-box").boundingBox(); + const card = await page.locator("[data-screen-card]").first().boundingBox(); + const scale = card!.width / (await contentSize(page)).w; + await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move( + box!.x + box!.width / 2 + 100 * scale, + box!.y + box!.height / 2, + { + steps: 16, + }, + ); + await page.mouse.up(); + await page.waitForTimeout(2000); + + const after = styleOf(await indexHtml(page, id), "plain-box"); + const moved = styleNum(after, "left") - styleNum(before, "left"); + // A drag-start threshold consumes the first few px, as in Figma. + expect( + moved > 90 && moved < 110, + `dragged 100 page-px right; left went ${styleNum(before, "left")} → ` + + `${styleNum(after, "left")} (moved ${moved}, expected within 10%)`, + ).toBe(true); + }); + + test("arrow-key nudge moves exactly one pixel", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1400); + const before = styleNum( + styleOf(await indexHtml(page, id), "plain-box"), + "left", + ); + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(1500); + expect( + styleNum(styleOf(await indexHtml(page, id), "plain-box"), "left"), + ).toBe(before + 1); + }); + + test("moving one element does not move any other", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + const introBefore = styleOf(await indexHtml(page, id), "intro"); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1400); + await page.keyboard.press("ArrowDown"); + await page.waitForTimeout(1500); + expect(styleOf(await indexHtml(page, id), "intro")).toBe(introBefore); + }); + + test("no move raises a user-facing failure toast", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1400); + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(1500); + const bad = (await toasts(page)).filter((t) => + /could not|failed|not found|error/i.test(t), + ); + expect(bad, `move surfaced: ${bad.join(" | ")}`).toHaveLength(0); + }); +}); + +// ── Selection ───────────────────────────────────────────────────────────── + +test.describe("selection", () => { + test("clicking selects the deepest node, and Escape walks up to the parent", async ({ + page, + }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + + // Deliberate divergence from Figma (see selectionTargetForHit in + // editor-chrome.bridge.ts): this canvas is real HTML, where climbing to + // the outermost ancestor makes a label select its whole container. + await selectOnCanvas(page, "intro-title"); + const clicked = ( + await page + .locator('[role="treeitem"][aria-selected="true"]') + .first() + .textContent() + )?.trim(); + expect( + clicked, + `a plain click selects the deepest node under the pointer; got "${clicked}"`, + ).toContain("Title"); + + await page.keyboard.press("Escape"); + await page.waitForTimeout(1500); + const parent = ( + await page + .locator('[role="treeitem"][aria-selected="true"]') + .first() + .textContent() + )?.trim(); + expect( + parent, + `Escape is how this editor reaches the ancestor Figma would have picked ` + + `on click; got "${parent}"`, + ).toContain("Intro"); + }); + + test("selecting a second element deselects the first", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1200); + await layerRow(page, "Title").click(); + await page.waitForTimeout(1200); + await expect( + page.locator('[role="treeitem"][aria-selected="true"]'), + ).toHaveCount(1); + }); +}); + +// ── The document is the source of truth ─────────────────────────────────── + +test.describe("persistence and history", () => { + test("a reload changes nothing", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + const before = await indexHtml(page, id); + await openEditor(page, id); + expect(await indexHtml(page, id)).toBe(before); + }); + + test("idling in the editor changes nothing", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + const before = await indexHtml(page, id); + await page.waitForTimeout(6000); + expect(await indexHtml(page, id)).toBe(before); + }); + + test("undo restores the exact previous document", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + const before = await indexHtml(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1400); + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(1500); + await page.keyboard.press(`${MOD}+z`); + await page.waitForTimeout(2000); + expect(await indexHtml(page, id)).toBe(before); + }); + + test("redo restores the exact post-edit document", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1400); + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(1500); + const edited = await indexHtml(page, id); + await page.keyboard.press(`${MOD}+z`); + await page.waitForTimeout(1800); + await page.keyboard.press(`${MOD}+Shift+z`); + await page.waitForTimeout(1800); + expect(await indexHtml(page, id)).toBe(edited); + }); +}); + +// ── Layer tree mirrors the document ─────────────────────────────────────── + +test.describe("layers panel", () => { + test("tree nesting matches DOM nesting", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + const rows = await layersTree(page) + .getByRole("treeitem") + .evaluateAll((els) => + els.map((el) => ({ + level: Number(el.getAttribute("aria-level")), + text: (el.textContent ?? "").trim().slice(0, 20), + })), + ); + const intro = rows.find((r) => r.text.includes("Intro")); + const title = rows.find((r) => r.text.includes("Title")); + expect(intro, "Intro missing from the layers tree").toBeTruthy(); + expect(title, "Title missing from the layers tree").toBeTruthy(); + expect( + title!.level, + `Title is a DOM child of Intro (level ${intro!.level}) but sits at level ${title!.level}`, + ).toBe(intro!.level + 1); + }); + + test("deleting a layer removes it from the document", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + await layerRow(page, "Plain Box").click(); + await page.waitForTimeout(1200); + await page.keyboard.press("Delete"); + await page.waitForTimeout(2000); + expect(await indexHtml(page, id)).not.toContain( + 'data-agent-native-node-id="plain-box"', + ); + }); + + test("hiding a layer hides it in the preview", async ({ page }) => { + const id = await newDesign(page, INTRO_PAGE); + await openEditor(page, id); + const row = layerRow(page, "Plain Box"); + await row.hover(); + await row.getByRole("button", { name: "Hide layer" }).first().click(); + await page.waitForTimeout(2000); + const visible = await node(page, "plain-box") + .evaluate((el) => { + const cs = getComputedStyle(el); + return ( + cs.display !== "none" && + cs.visibility !== "hidden" && + Number(cs.opacity) > 0 + ); + }) + .catch(() => false); + expect(visible, "layer marked hidden still paints in the preview").toBe( + false, + ); + }); +}); + +// ── Nothing should throw ────────────────────────────────────────────────── + +test("basic authoring raises no uncaught page errors", async ({ page }) => { + const id = await newDesign(page, BLANK_PAGE); + await openEditor(page, id); + await drawWith(page, "Rectangle", { + left: 100, + top: 100, + width: 200, + height: 120, + }); + await drawWith(page, "Frame", { + left: 400, + top: 100, + width: 300, + height: 200, + }); + await page.keyboard.press(`${MOD}+z`); + await page.waitForTimeout(1500); + await page.keyboard.press(`${MOD}+Shift+z`); + await page.waitForTimeout(1500); + expect(pageErrors, `uncaught errors: ${pageErrors.join(" | ")}`).toEqual([]); +}); diff --git a/templates/design/e2e/components.spec.ts b/templates/design/e2e/components.spec.ts new file mode 100644 index 0000000000..65e6c6909e --- /dev/null +++ b/templates/design/e2e/components.spec.ts @@ -0,0 +1,255 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Design has no component template: an instance is any node carrying + * `data-agent-native-component="Name"`, and same-named instances are + * independent copies (see the DESIGN NOTE in swap-component-instance.ts), so + * Figma parity is not the bar for the last describe block. + */ + +const FIXTURE = ` + + Components + + + +
+

Card body

+
+ +`; + +let baseURL = ""; + +async function postAction( + page: Page, + name: string, + input: Record, +) { + const res = await page.request.post( + `${baseURL}/_agent-native/actions/${name}`, + { data: input, headers: { "Content-Type": "application/json" } }, + ); + if (!res.ok()) + throw new Error( + `${name}: ${res.status()} ${(await res.text()).slice(0, 300)}`, + ); + return res.json(); +} + +async function newDesign(page: Page): Promise { + const created = await postAction(page, "create-design", { + title: "components", + projectType: "prototype", + }); + const id = created?.id ?? created?.data?.id; + if (!id) throw new Error("create-design returned no id"); + await postAction(page, "create-file", { + designId: id, + filename: "index.html", + content: FIXTURE, + fileType: "html", + }); + return id; +} + +async function indexHtml(page: Page, designId: string): Promise { + const record = await page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${designId}`) + .then((r) => r.json()); + return ( + (record.files ?? []).find((f: any) => f.filename === "index.html") + ?.content ?? "" + ); +} + +/** The open tag of one node, where every stamped annotation lives. */ +function openTag(html: string, nodeId: string): string { + const at = html.indexOf(`data-agent-native-node-id="${nodeId}"`); + if (at === -1) return ""; + const start = html.lastIndexOf("<", at); + return html.slice(start, html.indexOf(">", at) + 1); +} + +test.beforeAll(async ({}, testInfo) => { + baseURL = + (testInfo.project.use as { baseURL?: string }).baseURL ?? + process.env.E2E_BASE_URL ?? + "http://127.0.0.1:9333"; +}); + +test.describe("promoting to a component", () => { + test("create-component marks the node as a component instance", async ({ + page, + }) => { + const id = await newDesign(page); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-a", + name: "PrimaryButton", + }); + expect( + openTag(await indexHtml(page, id), "btn-a"), + "create-component must stamp data-agent-native-component on the node", + ).toContain('data-agent-native-component="PrimaryButton"'); + }); + + test("an existing variant-like attribute becomes a component prop", async ({ + page, + }) => { + const id = await newDesign(page); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-a", + name: "PrimaryButton", + }); + expect( + openTag(await indexHtml(page, id), "btn-a"), + `create-component documents that it stamps data-agent-native-prop-* for ` + + `variant-like attributes already on the node (btn-a has data-variant).`, + ).toContain("data-agent-native-prop-variant"); + }); + + test("promoting one node leaves its siblings untouched", async ({ page }) => { + const id = await newDesign(page); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-a", + name: "PrimaryButton", + }); + expect( + openTag(await indexHtml(page, id), "btn-b"), + "promoting one element must not annotate any other element", + ).not.toContain("data-agent-native-component"); + }); + + test("a promoted component is listed by list-design-components", async ({ + page, + }) => { + const id = await newDesign(page); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-a", + name: "PrimaryButton", + }); + const listed = await page.request + .get( + `${baseURL}/_agent-native/actions/list-design-components?designId=${id}`, + ) + .then((r) => r.json()); + const names = JSON.stringify(listed); + expect( + names, + `a promoted component must be discoverable — swap-component-instance ` + + `takes its targetComponentName "from list-design-components".`, + ).toContain("PrimaryButton"); + }); +}); + +test.describe("detaching an instance", () => { + test("detach strips the component linkage", async ({ page }) => { + const id = await newDesign(page); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-a", + name: "PrimaryButton", + }); + await postAction(page, "detach-component-instance", { + designId: id, + nodeId: "btn-a", + }); + expect( + openTag(await indexHtml(page, id), "btn-a"), + `Figma's Detach instance severs the linkage — the annotation must go.`, + ).not.toContain('data-agent-native-component="PrimaryButton"'); + }); + + test("detach preserves the rendered content", async ({ page }) => { + const id = await newDesign(page); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-a", + name: "PrimaryButton", + }); + await postAction(page, "detach-component-instance", { + designId: id, + nodeId: "btn-a", + }); + const html = await indexHtml(page, id); + expect( + html, + `Figma: detaching keeps the visual result identical, it only breaks the ` + + `link. The node's markup already IS the expanded content here.`, + ).toContain("Buy now"); + expect(openTag(html, "btn-a")).toContain("background:#3b82f6"); + }); +}); + +test.describe("swapping an instance", () => { + test("swap replaces the instance with the target component's markup", async ({ + page, + }) => { + const id = await newDesign(page); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-a", + name: "PrimaryButton", + }); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-b", + name: "SecondaryButton", + }); + await postAction(page, "swap-component-instance", { + designId: id, + nodeId: "btn-a", + targetComponentName: "SecondaryButton", + }); + const tag = openTag(await indexHtml(page, id), "btn-a"); + expect( + tag, + `Figma's Swap instance repoints the instance at the other component.`, + ).toContain('data-agent-native-component="SecondaryButton"'); + }); +}); + +test.describe("Design's own component model (not Figma parity)", () => { + test("same-named instances are independent copies, not linked to a main", async ({ + page, + }) => { + const id = await newDesign(page); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-a", + name: "Btn", + }); + await postAction(page, "create-component", { + designId: id, + nodeId: "btn-b", + name: "Btn", + }); + await postAction(page, "apply-visual-edit", { + source: { kind: "design-file", designId: id, filename: "index.html" }, + intent: { + kind: "style", + target: { nodeId: "btn-a" }, + property: "background", + value: "rgb(255, 0, 0)", + }, + }); + const html = await indexHtml(page, id); + expect( + openTag(html, "btn-b"), + `Design has no component template: "every instance of the same name is ` + + `an independently-duplicated copy of HTML" (swap-component-instance.ts). ` + + `Editing one must NOT propagate — this is a deliberate divergence from ` + + `Figma, where a main-component edit updates every instance.`, + ).not.toContain("rgb(255, 0, 0)"); + expect(openTag(html, "btn-a")).toContain("rgb(255, 0, 0)"); + }); +}); diff --git a/templates/design/e2e/constraints-breakpoints.spec.ts b/templates/design/e2e/constraints-breakpoints.spec.ts new file mode 100644 index 0000000000..d175e39b14 --- /dev/null +++ b/templates/design/e2e/constraints-breakpoints.spec.ts @@ -0,0 +1,414 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +/** + * Constraints assert Figma parity (doc-quoted). Breakpoints assert Design's + * OWN Framer-model contract from .agents/skills/responsive-breakpoints — they + * are deliberately not a Figma concept, so parity is not the bar there. + */ + +const PAGE_W = 1440; +const PAGE_H = 900; + +const FIXTURE = ` + + Constraints + +
+
+
+
+
+
+
+ +`; + +let baseURL = ""; + +async function postAction( + page: Page, + name: string, + input: Record, +) { + const res = await page.request.post( + `${baseURL}/_agent-native/actions/${name}`, + { + data: input, + headers: { "Content-Type": "application/json" }, + }, + ); + if (!res.ok()) + throw new Error( + `${name}: ${res.status()} ${(await res.text()).slice(0, 200)}`, + ); + return res.json(); +} + +async function newDesign(page: Page, content = FIXTURE): Promise { + const created = await postAction(page, "create-design", { + title: "constraints and breakpoints", + projectType: "prototype", + }); + const id = created?.id ?? created?.data?.id; + if (!id) throw new Error("create-design returned no id"); + await postAction(page, "create-file", { + designId: id, + filename: "index.html", + content, + fileType: "html", + }); + return id; +} + +async function designRecord(page: Page, designId: string) { + return page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${designId}`) + .then((r) => r.json()); +} + +async function indexHtml(page: Page, designId: string): Promise { + const record = await designRecord(page, designId); + return ( + (record.files ?? []).find((f: any) => f.filename === "index.html") + ?.content ?? "" + ); +} + +function toolbar(page: Page): Locator { + return page.locator("[data-design-bottom-toolbar]"); +} + +function layerRow(page: Page, name: string): Locator { + return page + .getByRole("tree", { name: "Layers" }) + .getByRole("treeitem") + .filter({ has: page.locator(`span[title="${name}"]`) }) + .first(); +} + +function node(page: Page, id: string): Locator { + return page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator(`[data-agent-native-node-id="${id}"]`); +} + +async function openEditor(page: Page, designId: string): Promise { + await page.goto(`${baseURL}/design/${designId}`, { + waitUntil: "domcontentloaded", + }); + await toolbar(page) + .locator('button[aria-label="Move"]') + .waitFor({ timeout: 45_000 }); + await page + .locator("iframe[data-design-preview-iframe]") + .first() + .waitFor({ timeout: 30_000 }); + await page.waitForTimeout(2500); + for (let i = 0; i < 5; i += 1) { + await page + .getByRole("button", { name: "Expand layer" }) + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(250); + } + await page.waitForTimeout(500); +} + +async function rendered(page: Page, id: string) { + return node(page, id).evaluate((el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, top: r.top, width: r.width, height: r.height }; + }); +} + +/** Resize the parent through the inspector, isolating constraints from the + * broken resize handles. */ +async function setWidth(page: Page, value: string): Promise { + const input = page + .getByText("W", { exact: true }) + .first() + .locator("xpath=following::input[1]"); + await input.fill(value); + await input.press("Enter"); + await page.waitForTimeout(2200); +} + +async function openConstraints(page: Page): Promise { + const toggle = page.locator('button[aria-label="Constraints"]'); + if ((await toggle.count()) === 0) return false; + await toggle.first().click(); + await page.waitForTimeout(1200); + return true; +} + +test.use({ viewport: { width: 1600, height: 1000 } }); + +test.beforeEach(async ({ page }, testInfo) => { + baseURL = + (testInfo.project.use.baseURL as string | undefined) ?? + process.env.E2E_BASE_URL ?? + `http://127.0.0.1:${process.env.E2E_PORT ?? 9333}`; +}); + +test.describe("constraints (Figma parity)", () => { + test("a child defaults to Top and Left constraints", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await layerRow(page, "Child").click(); + await page.waitForTimeout(1500); + expect( + await openConstraints(page), + "no Constraints control for a child of a frame", + ).toBe(true); + + const text = await page.evaluate(() => { + const heading = Array.from( + document.querySelectorAll("*"), + ).find( + (el) => + el.children.length === 0 && + (el.textContent ?? "").trim() === "Constraints", + ); + return ( + heading?.parentElement?.parentElement?.innerText?.replace( + /\s+/g, + " ", + ) ?? "" + ); + }); + expect( + text, + `Figma: "By default, constraints are set to Top and Left". Panel reads "${text}".`, + ).toMatch(/Left/i); + expect(text).toMatch(/Top/i); + }); + + test("a Left+Top child keeps its offset when the parent widens", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + const before = await rendered(page, "child"); + const parentBefore = await rendered(page, "parent"); + + await layerRow(page, "Parent").click(); + await page.waitForTimeout(1500); + await setWidth(page, "600"); + + const after = await rendered(page, "child"); + const parentAfter = await rendered(page, "parent"); + test.skip( + Math.abs(parentAfter.width - parentBefore.width) < 1, + "the parent did not actually resize, so constraints are untestable here", + ); + expect( + Math.round(after.left - parentAfter.left), + `Figma: Top+Left "will stay in the same position relative to the top left corner of ` + + `its parent frame". Offset went ${Math.round(before.left - parentBefore.left)} → ` + + `${Math.round(after.left - parentAfter.left)}.`, + ).toBe(Math.round(before.left - parentBefore.left)); + }); + + test("a Scale-constrained child keeps its percentage of the parent width", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await layerRow(page, "Child").click(); + await page.waitForTimeout(1500); + const opened = await openConstraints(page); + test.skip(!opened, "no Constraints control to set Scale with"); + + const scaleOption = page + .getByRole("option", { name: /Scale/i }) + .or(page.getByRole("menuitem", { name: /Scale/i })); + const hasScale = await scaleOption.count(); + test.skip(hasScale === 0, "no Scale constraint option exposed"); + await scaleOption.first().click(); + await page.waitForTimeout(1500); + + const childBefore = await rendered(page, "child"); + const parentBefore = await rendered(page, "parent"); + const ratio = childBefore.width / parentBefore.width; + + await layerRow(page, "Parent").click(); + await page.waitForTimeout(1500); + await setWidth(page, "800"); + + const childAfter = await rendered(page, "child"); + const parentAfter = await rendered(page, "parent"); + expect( + childAfter.width / parentAfter.width, + `Figma: Scale "will define the layer's size and position as a percentage of the ` + + `frame's dimensions" — 70px in a 100px frame becomes 140px in a 200px frame. ` + + `Ratio went ${ratio.toFixed(3)} → ${(childAfter.width / parentAfter.width).toFixed(3)}.`, + ).toBeCloseTo(ratio, 2); + }); + + test("constraints are not offered for a child of an auto-layout frame", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await layerRow(page, "Auto Child").click(); + await page.waitForTimeout(1500); + expect( + await page.locator('button[aria-label="Constraints"]').count(), + `Figma: "It's not possible to apply constraints to layers ... in an auto layout frame."`, + ).toBe(0); + }); +}); + +test.describe("breakpoints (Design's Framer model, not Figma)", () => { + test("add-breakpoint records the width in the design's breakpointSet", async ({ + page, + }) => { + const id = await newDesign(page); + await postAction(page, "add-breakpoint", { + designId: id, + label: "Tablet", + widthPx: 810, + }); + const record = await designRecord(page, id); + const data = + typeof record.data === "string" + ? JSON.parse(record.data || "{}") + : (record.data ?? {}); + const widths = (data.breakpointSet?.breakpoints ?? []).map( + (b: any) => b.widthPx, + ); + expect( + widths, + `skill: add-breakpoint "adds a device-width frame to designs.data.breakpointSet". ` + + `breakpointSet.breakpoints is ${JSON.stringify(data.breakpointSet?.breakpoints ?? null)}.`, + ).toContain(810); + }); + + test("a duplicate breakpoint width is ignored", async ({ page }) => { + const id = await newDesign(page); + await postAction(page, "add-breakpoint", { + designId: id, + label: "Tablet", + widthPx: 810, + }); + await postAction(page, "add-breakpoint", { + designId: id, + label: "Tablet", + widthPx: 810, + }).catch(() => {}); + const record = await designRecord(page, id); + const data = + typeof record.data === "string" + ? JSON.parse(record.data || "{}") + : (record.data ?? {}); + const widths = (data.breakpointSet?.breakpoints ?? []).map( + (b: any) => b.widthPx, + ); + expect( + widths.filter((w: number) => w === 810).length, + `skill: "Duplicate widths are ignored". Got ${JSON.stringify(widths)}.`, + ).toBe(1); + }); + + test("an edit at a narrower breakpoint scopes to next-wider minus one", async ({ + page, + }) => { + const id = await newDesign(page); + await postAction(page, "add-breakpoint", { + designId: id, + label: "Tablet", + widthPx: 810, + }); + await postAction(page, "add-breakpoint", { + designId: id, + label: "Phone", + widthPx: 390, + }); + await postAction(page, "apply-visual-edit", { + source: { kind: "design-file", designId: id, filename: "index.html" }, + intent: { + kind: "style", + target: { nodeId: "child" }, + property: "background", + value: "rgb(255, 0, 0)", + }, + activeFrameWidthPx: 390, + }); + + const html = await indexHtml(page, id); + const media = /@media[^{]*max-width:\s*(\d+)px/gi; + const bounds: number[] = []; + let m: RegExpExecArray | null; + while ((m = media.exec(html))) bounds.push(Number(m[1])); + expect( + bounds, + `skill: "The bound for an override is next-wider frame width - 1" — editing at 390 ` + + `with an 810 breakpoint present must scope to max-width: 809px. Found ${JSON.stringify(bounds)}.`, + ).toContain(809); + }); + + test("a breakpoint edit persists when the document has no head", async ({ + page, + }) => { + const id = await newDesign( + page, + FIXTURE.replace(/\s*[\s\S]*?<\/head>/i, ""), + ); + await postAction(page, "add-breakpoint", { + designId: id, + label: "Tablet", + widthPx: 810, + }); + await postAction(page, "add-breakpoint", { + designId: id, + label: "Phone", + widthPx: 390, + }); + await postAction(page, "apply-visual-edit", { + source: { kind: "design-file", designId: id, filename: "index.html" }, + intent: { + kind: "style", + target: { nodeId: "child" }, + property: "background", + value: "rgb(255, 0, 0)", + }, + activeFrameWidthPx: 390, + }); + + const html = await indexHtml(page, id); + expect(html).toContain("max-width: 809px"); + expect(html.indexOf("")); + }); + + test("the default device set is a desktop base plus mobile only", async ({ + page, + }) => { + const id = await newDesign(page); + const record = await designRecord(page, id); + const data = + typeof record.data === "string" + ? JSON.parse(record.data || "{}") + : (record.data ?? {}); + const widths = (data.breakpointSet?.breakpoints ?? []).map( + (b: any) => b.widthPx, + ); + test.skip( + widths.length === 0, + "create-design injects no breakpointSet; the documented default applies to generate-design", + ); + expect( + widths, + `skill: the default injected set is "a Desktop base plus a single Mobile (390) ` + + `breakpoint frame ... never an auto-added tablet". Got ${JSON.stringify(widths)}.`, + ).not.toContain(810); + }); +}); diff --git a/templates/design/e2e/drag-and-drop.spec.ts b/templates/design/e2e/drag-and-drop.spec.ts new file mode 100644 index 0000000000..cc3cc51cb5 --- /dev/null +++ b/templates/design/e2e/drag-and-drop.spec.ts @@ -0,0 +1,1031 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +/** + * Direct-manipulation contract: what you grab is what moves, and a drag tells + * you what it will do before you commit it. Assertions are Figma's behaviour. + * + * Real hooks (discovered, do not invent others): [data-resize-handle] x8, + * [data-rotate-handle] x4, [data-screen-hover-outline]. + */ + +const PAGE_W = 1440; +const PAGE_H = 900; +const MOD = process.platform === "darwin" ? "Meta" : "Control"; +const ALT = "Alt"; + +const FIXTURE = ` + + DnD + +
+
+
+
+
+
+
+
+ +`; + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +let baseURL = ""; + +async function postAction( + page: Page, + name: string, + input: Record, +) { + const res = await page.request.post( + `${baseURL}/_agent-native/actions/${name}`, + { + data: input, + headers: { "Content-Type": "application/json" }, + }, + ); + if (!res.ok()) { + throw new Error( + `${name}: ${res.status()} ${(await res.text()).slice(0, 200)}`, + ); + } + return res.json(); +} + +async function newDesign(page: Page, content = FIXTURE): Promise { + const created = await postAction(page, "create-design", { + title: "drag and drop", + projectType: "prototype", + }); + const id = created?.id ?? created?.data?.id; + if (!id) throw new Error("create-design returned no id"); + await postAction(page, "create-file", { + designId: id, + filename: "index.html", + content, + fileType: "html", + }); + return id; +} + +async function indexHtml(page: Page, designId: string): Promise { + const result = await page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${designId}`) + .then((r) => r.json()); + return ( + (result.files ?? []).find((f: any) => f.filename === "index.html") + ?.content ?? "" + ); +} + +function styleOf(html: string, id: string): string { + return ( + new RegExp( + `data-agent-native-node-id="${id}"[^>]*?style="([^"]*)"`, + "i", + ).exec(html)?.[1] ?? "" + ); +} + +function styleNum(style: string, prop: string): number { + const m = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*(-?[\\d.]+)px`, "i").exec( + style, + ); + return m ? Number(m[1]) : NaN; +} + +async function geom(page: Page, designId: string, id: string) { + const s = styleOf(await indexHtml(page, designId), id); + return { + left: styleNum(s, "left"), + top: styleNum(s, "top"), + width: styleNum(s, "width"), + height: styleNum(s, "height"), + style: s, + }; +} + +function toolbar(page: Page): Locator { + return page.locator("[data-design-bottom-toolbar]"); +} + +function layersTree(page: Page): Locator { + return page.getByRole("tree", { name: "Layers" }); +} + +function layerRow(page: Page, name: string): Locator { + return layersTree(page) + .getByRole("treeitem") + .filter({ hasText: name }) + .first(); +} + +function node(page: Page, id: string): Locator { + return page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator(`[data-agent-native-node-id="${id}"]`); +} + +async function openEditor(page: Page, designId: string): Promise { + await page.goto(`${baseURL}/design/${designId}`, { + waitUntil: "domcontentloaded", + }); + await toolbar(page) + .locator('button[aria-label="Move"]') + .waitFor({ timeout: 45_000 }); + await page + .locator("iframe[data-design-preview-iframe]") + .first() + .waitFor({ timeout: 30_000 }); + await page.waitForTimeout(2500); + for (let i = 0; i < 4; i += 1) { + await page + .getByRole("button", { name: "Expand layer" }) + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(250); + } + await page.waitForTimeout(500); +} + +/** Screen px per page px, so a drag can be expressed in page units. */ +async function scale(page: Page): Promise { + const card = await page.locator("[data-screen-card]").first().boundingBox(); + if (!card) throw new Error("no screen card"); + // Never assume the page size — the screen's own viewport is the truth. + const inner = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => document.documentElement.clientWidth); + return card.width / inner; +} + +/** The rect the resize/rotate handles enclose. */ +async function chromeBounds(page: Page) { + return page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => { + const el = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + if (!el) return null; + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) return null; + return { + left: Math.round(r.left), + top: Math.round(r.top), + right: Math.round(r.right), + bottom: Math.round(r.bottom), + }; + }); +} + +/** Overlays the bridge paints inside the iframe, with a non-zero box. */ +async function activeOverlays(page: Page): Promise { + return page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => + Array.from(document.querySelectorAll("[data-agent-native-edit-overlay]")) + .filter((el) => { + const r = el.getBoundingClientRect(); + return r.width > 0 || r.height > 0; + }) + .map((el) => el.getAttribute("data-agent-native-edit-overlay") ?? ""), + ); +} + +async function selectViaTree(page: Page, name: string): Promise { + await layerRow(page, name).click(); + await page.waitForTimeout(1600); +} + +async function dragBy( + page: Page, + from: Box, + dxPage: number, + dyPage: number, + options?: { modifier?: string; cancel?: boolean }, +): Promise { + const s = await scale(page); + const cx = from.x + from.width / 2; + const cy = from.y + from.height / 2; + if (options?.modifier) await page.keyboard.down(options.modifier); + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx + dxPage * s, cy + dyPage * s, { steps: 16 }); + await page.waitForTimeout(350); + if (options?.cancel) await page.keyboard.press("Escape"); + await page.mouse.up(); + if (options?.modifier) await page.keyboard.up(options.modifier); + await page.waitForTimeout(2000); +} + +test.use({ viewport: { width: 1600, height: 1000 } }); + +test.beforeEach(async ({ page }, testInfo) => { + baseURL = + (testInfo.project.use.baseURL as string | undefined) ?? + process.env.E2E_BASE_URL ?? + `http://127.0.0.1:${process.env.E2E_PORT ?? 9333}`; +}); + +test.describe("selection chrome", () => { + test("handles wrap the selected element, not the screen", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + + const target = await node(page, "box-a").evaluate((el) => { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }); + const chrome = await chromeBounds(page); + expect( + chrome, + "no selection overlay appeared for a selected element", + ).not.toBeNull(); + const width = chrome!.right - chrome!.left; + const height = chrome!.bottom - chrome!.top; + expect( + [width, height], + `Box A is ${Math.round(target.width)}x${Math.round(target.height)} at ` + + `(${Math.round(target.x)},${Math.round(target.y)}), but the selection handles enclose ` + + `${width}x${height} at (${chrome!.left},${chrome!.top}) — the whole screen. ` + + `You cannot grab or resize what you selected.`, + ).toEqual([ + expect.closeTo(target.width, -1.4), + expect.closeTo(target.height, -1.4), + ]); + }); + + test("selecting an element paints a selection overlay", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + expect(await activeOverlays(page)).toContain("selection"); + }); + + test("selecting a different element moves the chrome to it", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const first = await chromeBounds(page); + await selectViaTree(page, "Box B"); + const second = await chromeBounds(page); + expect( + second, + `chrome stayed at the same rect after selecting a different layer`, + ).not.toEqual(first); + }); + + test("hovering an element outlines that element", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + const target = await node(page, "box-a").evaluate((el) => { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }); + const onScreen = (await node(page, "box-a").boundingBox())!; + await page.mouse.move( + onScreen.x + onScreen.width / 2, + onScreen.y + onScreen.height / 2, + ); + await page.waitForTimeout(1200); + + // The hover indicator is painted inside the iframe, not the host document. + const highlight = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => { + const el = document.querySelector( + '[data-agent-native-edit-overlay="highlight"]', + ); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { w: Math.round(r.width), h: Math.round(r.height) }; + }); + expect( + highlight, + "hovering an element painted no highlight overlay", + ).not.toBeNull(); + expect( + [highlight!.w, highlight!.h], + `hovering a ${Math.round(target.width)}x${Math.round(target.height)} box ` + + `highlighted ${highlight!.w}x${highlight!.h}`, + ).toEqual([ + expect.closeTo(target.width, -1), + expect.closeTo(target.height, -1), + ]); + }); +}); + +test.describe("moving by drag", () => { + test("a drag moves the element by the drag delta", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const before = await geom(page, id, "box-a"); + await dragBy(page, (await node(page, "box-a").boundingBox())!, 120, 60); + const after = await geom(page, id, "box-a"); + const dx = after.left - before.left; + const dy = after.top - before.top; + // A drag-start threshold consumes the first few px (Figma does the same), + // so assert the movement is proportional rather than exact. + expect( + [dx / 120 > 0.9 && dx / 120 < 1.1, dy / 60 > 0.9 && dy / 60 < 1.1], + `dragged (120,60) page px; moved (${dx},${dy}) — expected within 10%`, + ).toEqual([true, true]); + }); + + test("Shift+drag locks movement to one axis", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const before = await geom(page, id, "box-a"); + await dragBy(page, (await node(page, "box-a").boundingBox())!, 120, 30, { + modifier: "Shift", + }); + const after = await geom(page, id, "box-a"); + expect( + after.top, + `Shift+drag moved mostly horizontally but top changed ${before.top} → ${after.top}`, + ).toBe(before.top); + }); + + test("Alt+drag leaves the original and creates a copy", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const before = await indexHtml(page, id); + const countBefore = ( + before.match(/data-agent-native-layer-name="Box A"/g) ?? [] + ).length; + await dragBy(page, (await node(page, "box-a").boundingBox())!, 150, 0, { + modifier: ALT, + }); + const after = await indexHtml(page, id); + expect( + (after.match(/data-agent-native-layer-name="Box A"/g) ?? []).length, + `Alt+drag should duplicate; Box A count stayed ${countBefore}`, + ).toBeGreaterThan(countBefore); + }); + + test("Escape during a drag cancels the move", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const before = await geom(page, id, "box-a"); + await dragBy(page, (await node(page, "box-a").boundingBox())!, 200, 100, { + cancel: true, + }); + const after = await geom(page, id, "box-a"); + expect( + [after.left, after.top], + "Escape mid-drag must restore the start position", + ).toEqual([before.left, before.top]); + }); + + test("dragging one element leaves its siblings untouched", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const siblingBefore = (await geom(page, id, "box-b")).style; + await dragBy(page, (await node(page, "box-a").boundingBox())!, 80, 40); + expect((await geom(page, id, "box-b")).style).toBe(siblingBefore); + }); + + test("undo restores the position after a drag", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const before = await geom(page, id, "box-a"); + await dragBy(page, (await node(page, "box-a").boundingBox())!, 100, 50); + await page.keyboard.press(`${MOD}+z`); + await page.waitForTimeout(2000); + const after = await geom(page, id, "box-a"); + expect([after.left, after.top]).toEqual([before.left, before.top]); + }); +}); + +test.describe("resizing", () => { + test("dragging the bottom-right corner resizes width and height", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const before = await geom(page, id, "box-a"); + + // Element resize handles live INSIDE the iframe as children of the + // selection overlay; [data-resize-handle] in the host is the screen's own + // board chrome. + const iframeBox = (await page + .locator("iframe[data-design-preview-iframe]") + .first() + .boundingBox())!; + const s0 = await scale(page); + const cornerLocal = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => { + const handles = Array.from( + document.querySelectorAll("[data-agent-native-edit-handle]"), + ); + if (handles.length === 0) return null; + const se = handles + .map((h) => h.getBoundingClientRect()) + .sort((a, b) => b.x + b.y - (a.x + a.y))[0]; + return { x: se.x + se.width / 2, y: se.y + se.height / 2 }; + }); + const corner = cornerLocal + ? { + x: Math.round(iframeBox.x + cornerLocal.x * s0), + y: Math.round(iframeBox.y + cornerLocal.y * s0), + } + : null; + expect(corner, "no corner resize handle found").not.toBeNull(); + + const s = await scale(page); + await page.mouse.move(corner!.x, corner!.y); + await page.mouse.down(); + await page.mouse.move(corner!.x + 100 * s, corner!.y + 60 * s, { + steps: 14, + }); + await page.mouse.up(); + await page.waitForTimeout(2000); + + const after = await geom(page, id, "box-a"); + expect( + [after.width - before.width, after.height - before.height], + `dragged the SE corner by (100,60); size changed by ` + + `(${after.width - before.width},${after.height - before.height})`, + ).toEqual([expect.closeTo(100, -1), expect.closeTo(60, -1)]); + }); + + test("resizing keeps the opposite edge anchored", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const before = await geom(page, id, "box-a"); + + const corner = await page.evaluate(() => { + const small = Array.from( + document.querySelectorAll("[data-resize-handle]"), + ).filter((h) => h.getBoundingClientRect().width < 20); + if (small.length === 0) return null; + const se = small + .map((h) => h.getBoundingClientRect()) + .sort((a, b) => b.x + b.y - (a.x + a.y))[0]; + return { + x: Math.round(se.x + se.width / 2), + y: Math.round(se.y + se.height / 2), + }; + }); + if (!corner) test.skip(true, "no corner handle to drag"); + const s = await scale(page); + await page.mouse.move(corner!.x, corner!.y); + await page.mouse.down(); + await page.mouse.move(corner!.x + 80 * s, corner!.y + 40 * s, { + steps: 12, + }); + await page.mouse.up(); + await page.waitForTimeout(2000); + + const after = await geom(page, id, "box-a"); + test.skip( + after.width === before.width && after.height === before.height, + "resize did not change the size, so anchoring is untested — see the SE corner test", + ); + expect( + [after.left, after.top], + "dragging the SE corner must not move the NW corner", + ).toEqual([before.left, before.top]); + }); +}); + +test.describe("reparenting and reordering", () => { + test("dragging an element into a container nests it", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const box = (await node(page, "box-a").boundingBox())!; + const target = (await node(page, "frame-a").boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move( + target.x + target.width / 2, + target.y + target.height / 2, + { steps: 20 }, + ); + await page.waitForTimeout(500); + await page.mouse.up(); + await page.waitForTimeout(2500); + + const nested = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => { + const parent = document.querySelector( + '[data-agent-native-node-id="frame-a"]', + ); + const child = document.querySelector( + '[data-agent-native-node-id="box-a"]', + ); + return !!parent && !!child && parent.contains(child); + }); + expect(nested, "dropping Box A onto Container did not reparent it").toBe( + true, + ); + }); + + test("dragging within an auto-layout row reorders it", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + const first = (await node(page, "chip-1").boundingBox())!; + const third = (await node(page, "chip-3").boundingBox())!; + // Select on the canvas, not via the tree: the bridge owns drag state and + // a Layers-panel selection does not arm it. + await page.mouse.click( + first.x + first.width / 2, + first.y + first.height / 2, + ); + await page.waitForTimeout(1200); + await page.mouse.move( + first.x + first.width / 2, + first.y + first.height / 2, + ); + await page.mouse.down(); + // A short first move starts the native drag; jumping straight to the + // target never leaves the source and no reorder is ever computed. + await page.mouse.move( + first.x + first.width / 2 + 12, + first.y + first.height / 2, + { steps: 5 }, + ); + await page.mouse.move( + third.x + third.width - 4, + third.y + third.height / 2, + { + steps: 24, + }, + ); + await page.waitForTimeout(800); + await page.mouse.up(); + await page.waitForTimeout(2500); + + const html = await indexHtml(page, id); + expect( + html.indexOf("chip-1"), + "dragging Chip 1 past Chip 3 did not reorder the auto-layout row", + ).toBeGreaterThan(html.indexOf("chip-3")); + }); + + test("dragging a layer row onto a container row reparents it", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await layerRow(page, "Box A").dragTo(layerRow(page, "Container")); + await page.waitForTimeout(2500); + + const nested = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => { + const parent = document.querySelector( + '[data-agent-native-node-id="frame-a"]', + ); + const child = document.querySelector( + '[data-agent-native-node-id="box-a"]', + ); + return !!parent && !!child && parent.contains(child); + }); + expect( + nested, + "dragging the layer row onto Container did not reparent", + ).toBe(true); + }); +}); + +test.describe("reparenting rules", () => { + test("an object smaller than a frame becomes its child when dropped in", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const box = (await node(page, "box-a").boundingBox())!; + const target = (await node(page, "frame-a").boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move( + target.x + target.width / 2, + target.y + target.height / 2, + { steps: 20 }, + ); + await page.mouse.up(); + await page.waitForTimeout(2500); + + const nested = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => { + const parent = document.querySelector( + '[data-agent-native-node-id="frame-a"]', + ); + const child = document.querySelector( + '[data-agent-native-node-id="box-a"]', + ); + return !!parent && !!child && parent.contains(child); + }); + expect( + nested, + 'Figma: "If an object is smaller than a frame, we will make it a child of the frame."', + ).toBe(true); + }); + + test("holding Space while dragging keeps the object in its current parent", async ({ + page, + }) => { + const inRow = (target: Page) => + target + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => { + const row = document.querySelector( + '[data-agent-native-node-id="row"]', + ); + const chip = document.querySelector( + '[data-agent-native-node-id="chip-1"]', + ); + return !!row && !!chip && row.contains(chip); + }); + + // The control drag mutates the document, so the Space drag needs its own + // pristine design rather than the one the control already reparented. + const controlId = await newDesign(page); + await openEditor(page, controlId); + await selectViaTree(page, "Chip 1"); + let chip = (await node(page, "chip-1").boundingBox())!; + let outside = (await node(page, "frame-a").boundingBox())!; + await page.mouse.move(chip.x + chip.width / 2, chip.y + chip.height / 2); + await page.mouse.down(); + await page.mouse.move( + outside.x + outside.width / 2, + outside.y + outside.height / 2, + { steps: 18 }, + ); + await page.mouse.up(); + await page.waitForTimeout(2200); + test.skip( + await inRow(page), + "an unmodified drag does not reparent either, so the Space modifier is untestable", + ); + + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Chip 1"); + chip = (await node(page, "chip-1").boundingBox())!; + outside = (await node(page, "frame-a").boundingBox())!; + + // The retain-parent flag is set by a keydown listener on the IFRAME + // document; page.keyboard sends to the host, where it only pans. + const previewBody = page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body"); + const spaceKey = (type: "keydown" | "keyup") => + previewBody.evaluate((_b, t) => { + document.dispatchEvent( + new KeyboardEvent(t, { + key: " ", + code: "Space", + bubbles: true, + cancelable: true, + }), + ); + }, type); + + await spaceKey("keydown"); + await page.mouse.move(chip.x + chip.width / 2, chip.y + chip.height / 2); + await page.mouse.down(); + await page.mouse.move( + outside.x + outside.width / 2, + outside.y + outside.height / 2, + { steps: 20 }, + ); + await page.mouse.up(); + await spaceKey("keyup"); + await page.waitForTimeout(2500); + + expect( + await inRow(page), + "Figma: \"When moving an object out of a frame's bounds, hold the Space bar to keep " + + 'an object within the current parent."', + ).toBe(true); + }); +}); + +test.describe("drag feedback", () => { + test("snap guides appear when an edge aligns with a sibling", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const a = (await node(page, "box-a").boundingBox())!; + const b = (await node(page, "box-b").boundingBox())!; + + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move(a.x + a.width / 2, b.y - a.height, { steps: 18 }); + await page.waitForTimeout(900); + const guides = (await activeOverlays(page)).filter((k) => + /snap-guide|measurement|transform-badge/.test(k), + ).length; + await page.mouse.up(); + await page.waitForTimeout(1000); + expect( + guides, + `Figma: "when using snap to settings ... a red guide appears on the canvas as a visual ` + + `indicator", and snap-to-objects "aligns the centers and outermost points of ` + + `different objects". No guide appeared.`, + ).toBeGreaterThan(0); + }); + + test("a container highlights as a drop target while dragging over it", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + const a = (await node(page, "box-a").boundingBox())!; + const target = (await node(page, "frame-a").boundingBox())!; + + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move( + target.x + target.width / 2, + target.y + target.height / 2, + { steps: 18 }, + ); + await page.waitForTimeout(900); + const highlights = (await activeOverlays(page)).filter((k) => + /insertion-guide|drop/.test(k), + ).length; + await page.mouse.up(); + await page.waitForTimeout(1000); + expect( + highlights, + `UNVERIFIED for a plain frame: Figma documents a blue indicator only for auto layout ` + + `containers, and says nothing about highlighting a plain frame. Treat as a usability ` + + `claim. No feedback of any kind appeared.`, + ).toBeGreaterThan(0); + }); + + test("the layers panel shows an insertion line while dragging a row", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + const src = (await layerRow(page, "Box A").boundingBox())!; + const dst = (await layerRow(page, "Box B").boundingBox())!; + await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2); + await page.mouse.down(); + // A short first move starts the native drag; jumping straight to the + // target never leaves the source row and no dragover fires. + await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2 + 8, { + steps: 4, + }); + await page.mouse.move(dst.x + dst.width / 2, dst.y + dst.height - 3, { + steps: 14, + }); + await page.waitForTimeout(600); + const indicators = await page + .locator("[data-layer-drop-indicator]") + .count(); + await page.mouse.up(); + await page.waitForTimeout(500); + expect( + indicators, + "Figma shows an insertion line while reordering layers", + ).toBeGreaterThan(0); + }); + + test("the cursor differs between the Move and Hand tools", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + const read = () => + page.evaluate(() => { + const world = document.querySelector( + "[data-multi-screen-canvas-world]", + ); + const surface = world?.parentElement ?? null; + return surface ? getComputedStyle(surface).cursor : null; + }); + const move = await read(); + await toolbar(page).locator('button[aria-label="Move options"]').click(); + await page.getByRole("menuitem", { name: /Hand/i }).first().click(); + await page.waitForTimeout(1200); + const hand = await read(); + expect(hand, `Move and Hand both show cursor "${move}"`).not.toBe(move); + }); +}); + +test.describe("drop containers", () => { + const dropFixture = ( + primitive: string, + ) => `Drop + +
+
+`; + + const dragMoverOntoTarget = async (page: Page, primitive: string) => { + const id = await newDesign(page, dropFixture(primitive)); + await openEditor(page, id); + const preview = page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame(); + const mover = (await preview + .locator('[data-agent-native-node-id="mover"]') + .boundingBox())!; + const target = (await preview + .locator('[data-agent-native-node-id="target"]') + .boundingBox())!; + await page.mouse.move( + mover.x + mover.width / 2, + mover.y + mover.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + mover.x + mover.width / 2 + 10, + mover.y + mover.height / 2, + { steps: 3 }, + ); + await page.mouse.move( + target.x + target.width / 2, + target.y + target.height / 2, + { steps: 20 }, + ); + await page.waitForTimeout(600); + await page.mouse.up(); + await page.waitForTimeout(2500); + return preview + .locator("body") + .evaluate( + () => + document + .querySelector('[data-agent-native-node-id="mover"]') + ?.parentElement?.getAttribute("data-agent-native-node-id") ?? null, + ); + }; + + test("a frame adopts an element dragged into it", async ({ page }) => { + expect( + await dragMoverOntoTarget(page, "frame"), + "frames are the container primitive and must adopt a dropped element", + ).toBe("target"); + }); + + test("a rectangle never adopts an element dragged onto it", async ({ + page, + }) => { + expect( + await dragMoverOntoTarget(page, "rectangle"), + "a rectangle is a vector shape, not a container — matching Figma and the " + + "same contract the draw path enforces", + ).not.toBe("target"); + }); +}); + +test.describe("modifier collisions", () => { + /** Synthetic input under-travels (the first move starts the drag and rAF + * coalesces the rest), so compare the two drags rather than absolutes. */ + const dragUp = async (page: Page, withModifier: boolean) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Box A"); + await page.waitForTimeout(1200); + const read = async () => + Number( + /box-a"[\s\S]{0,200}?top:\s*(-?\d+(?:\.\d+)?)px/.exec( + await indexHtml(page, id), + )?.[1] ?? NaN, + ); + const before = await read(); + const box = (await node(page, "box-a").boundingBox())!; + const s = box.height / 80; + const mod = process.platform === "darwin" ? "Meta" : "Control"; + if (withModifier) await page.keyboard.down(mod); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move( + box.x + box.width / 2, + box.y + box.height / 2 - 100 * s, + { + steps: 40, + }, + ); + await page.waitForTimeout(300); + await page.mouse.up(); + if (withModifier) await page.keyboard.up(mod); + await page.waitForTimeout(2200); + return before - (await read()); + }; + + test("the primary modifier does not hijack a drag", async ({ page }) => { + const plain = await dragUp(page, false); + const modified = await dragUp(page, true); + expect( + plain, + `an unmodified 100px drag should travel most of the way; moved ${plain}`, + ).toBeGreaterThan(85); + expect( + Math.abs(plain - modified), + `the primary modifier is Figma's snap bypass, not a selection change — ` + + `plain drag moved ${plain}, modified moved ${modified}. A large gap ` + + `means the chord (additive-select / deep-select) consumed the gesture.`, + ).toBeLessThanOrEqual(8); + }); +}); + +test.describe("marquee", () => { + test("dragging from empty canvas marquee-selects the elements it covers", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + // Starting outside the screen marquees the BOARD (screens), not the + // elements — the drag has to begin on empty space inside the screen. + const a = (await node(page, "box-a").boundingBox())!; + const scale = a.width / 120; + const originX = a.x - 30 * scale; + const originY = a.y - 280 * scale; + const at = (sx: number, sy: number) => ({ + x: originX + sx * scale, + y: originY + sy * scale, + }); + const from = at(180, 240); + const to = at(15, 545); + + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 20 }); + await page.waitForTimeout(400); + await page.mouse.up(); + await page.waitForTimeout(1800); + + await expect( + page.locator('[role="treeitem"][aria-selected="true"]'), + "a marquee across Box A and Box B should select both", + ).toHaveCount(2); + }); +}); diff --git a/templates/design/e2e/frame-screen-nesting.spec.ts b/templates/design/e2e/frame-screen-nesting.spec.ts new file mode 100644 index 0000000000..39b8851a3e --- /dev/null +++ b/templates/design/e2e/frame-screen-nesting.spec.ts @@ -0,0 +1,467 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Clip FFBTGvnWyEys "Fix Frame and Screen Nesting in Design Editor". + * On the board "frame" already means a screen card (data-frame-id), so the + * Frame tool is overloaded — these pin which surface produces which thing. + */ + +const BLANK = ` + + Home + +`; + +let baseURL = ""; + +async function postAction( + page: Page, + name: string, + input: Record, +) { + const res = await page.request.post( + `${baseURL}/_agent-native/actions/${name}`, + { + data: input, + headers: { "Content-Type": "application/json" }, + }, + ); + if (!res.ok()) + throw new Error( + `${name}: ${res.status()} ${(await res.text()).slice(0, 300)}`, + ); + return res.json(); +} + +async function newDesign(page: Page): Promise { + const created = await postAction(page, "create-design", { + title: "frame and screen nesting", + projectType: "prototype", + }); + const id = created?.id ?? created?.data?.id; + if (!id) throw new Error("create-design returned no id"); + await postAction(page, "create-file", { + designId: id, + filename: "index.html", + content: BLANK, + fileType: "html", + }); + return id; +} + +async function designFiles(page: Page, id: string): Promise { + const record = await page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${id}`) + .then((r) => r.json()); + return (record.files ?? []).map((f: any) => f.filename); +} + +async function fileContent( + page: Page, + id: string, + filename: string, +): Promise { + const record = await page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${id}`) + .then((r) => r.json()); + return ( + (record.files ?? []).find((f: any) => f.filename === filename)?.content ?? + "" + ); +} + +async function openEditor(page: Page, id: string): Promise { + await page.goto(`${baseURL}/design/${id}`, { waitUntil: "domcontentloaded" }); + await page + .locator('[data-design-bottom-toolbar] button[aria-label="Move"]') + .waitFor({ timeout: 45_000 }); + await page + .locator("iframe[data-design-preview-iframe]") + .first() + .waitFor({ timeout: 30_000 }); + await page.waitForTimeout(3500); +} + +/** Screen rect in page px, plus px-per-screen-unit. */ +async function screenBox(page: Page) { + const box = (await page + .locator("iframe[data-design-preview-iframe]") + .first() + .boundingBox())!; + return { ...box, scale: box.width / 320 }; +} + +/** + * Scans for a point that actually hit-tests to the canvas surface. Computing + * one from the screen rect lands on the inspector panel at narrow viewports. + */ +async function emptyBoardPoint(page: Page) { + const point = await page.evaluate(() => { + const world = document.querySelector("[data-multi-screen-canvas-world]"); + const surface = (world?.parentElement ?? world) as HTMLElement | null; + if (!surface) return null; + const r = surface.getBoundingClientRect(); + const cards = Array.from( + document.querySelectorAll("[data-screen-iframe-id]"), + ).map((el) => el.getBoundingClientRect()); + for (let y = r.top + 60; y < r.bottom - 60; y += 40) { + for (let x = r.left + 60; x < r.right - 60; x += 40) { + if ( + cards.some( + (c) => + x >= c.left - 24 && + x <= c.right + 24 && + y >= c.top - 24 && + y <= c.bottom + 24, + ) + ) { + continue; + } + const hit = document.elementFromPoint(x, y); + if (hit && surface.contains(hit)) return { x, y }; + } + } + return null; + }); + if (!point) throw new Error("no empty canvas point found at this viewport"); + return point; +} + +/** Frame is the primary tool; Screen lives in its dropdown. The trigger's + * label follows the active mode, so match either. */ +async function pickFrameMode(page: Page, mode: "Frame" | "Screen") { + await page + .locator( + '[data-design-bottom-toolbar] button[aria-label="Frame options"],' + + ' [data-design-bottom-toolbar] button[aria-label="Screen options"]', + ) + .first() + .click(); + await page.getByRole("menuitem").filter({ hasText: mode }).first().click(); + await page.waitForTimeout(600); +} + +async function drawFrameTool( + page: Page, + mode: "Frame" | "Screen", + from: { x: number; y: number }, + to: { x: number; y: number }, +) { + await pickFrameMode(page, mode); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 16 }); + await page.mouse.up(); + await page.waitForTimeout(3000); +} + +async function drawWith( + page: Page, + tool: string, + from: { x: number; y: number }, + to: { x: number; y: number }, +) { + await page + .locator(`[data-design-bottom-toolbar] button[aria-label="${tool}"]`) + .click(); + await page.waitForTimeout(500); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 16 }); + await page.mouse.up(); + await page.waitForTimeout(3000); +} + +test.beforeAll(async ({}, testInfo) => { + baseURL = + (testInfo.project.use as { baseURL?: string }).baseURL ?? + process.env.E2E_BASE_URL ?? + "http://127.0.0.1:9333"; +}); + +test("the Frame option remains sticky when reactivated with F", async ({ + page, +}) => { + const id = await newDesign(page); + await openEditor(page, id); + const empty = await emptyBoardPoint(page); + const filesBefore = await designFiles(page, id); + + await drawFrameTool(page, "Frame", empty, { + x: empty.x + 180, + y: empty.y + 140, + }); + expect(await designFiles(page, id)).toEqual(filesBefore); + expect( + (await fileContent(page, id, "__board__.html")).match( + /data-an-primitive="frame"/g, + ), + ).toHaveLength(1); + + await page.keyboard.press("f"); + await page.mouse.move(empty.x, empty.y + 180); + await page.mouse.down(); + await page.mouse.move(empty.x + 160, empty.y + 300, { steps: 16 }); + await page.mouse.up(); + await page.waitForTimeout(3000); + expect(await designFiles(page, id)).toEqual(filesBefore); + expect( + (await fileContent(page, id, "__board__.html")).match( + /data-an-primitive="frame"/g, + ), + ).toHaveLength(2); +}); + +test("the Screen option creates a screen after selecting Frame", async ({ + page, +}) => { + const id = await newDesign(page); + await openEditor(page, id); + const filesBefore = await designFiles(page, id); + + await page + .locator('[data-design-bottom-toolbar] button[aria-label="Frame"]') + .click(); + await page.waitForTimeout(400); + + const empty = await emptyBoardPoint(page); + await drawFrameTool(page, "Screen", empty, { + x: empty.x + 200, + y: empty.y + 150, + }); + expect(await designFiles(page, id)).toHaveLength(filesBefore.length + 1); +}); + +test("the live board uses the light canvas theme token", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + await page.addInitScript(() => localStorage.setItem("theme", "light")); + const id = await newDesign(page); + await openEditor(page, id); + const empty = await emptyBoardPoint(page); + await drawWith(page, "Rectangle", empty, { + x: empty.x + 160, + y: empty.y + 120, + }); + + await expect(page.locator("html")).toHaveClass(/light/); + await expect( + page.locator( + "[data-board-surface-layer] iframe[data-design-preview-iframe]", + ), + ).toHaveCSS("background-color", "rgb(235, 235, 235)"); +}); + +test("the development interaction trace exposes a dump", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + + await expect + .poll(() => + page.evaluate(() => typeof window.__designTrace?.dump === "function"), + ) + .toBe(true); + const dump = await page.evaluate(() => window.__designTrace!.dump()); + expect(dump).toContain("["); +}); + +test("1:19 — the Frame tool inside a screen makes a frame, not a screen", async ({ + page, +}) => { + const id = await newDesign(page); + await openEditor(page, id); + const screen = await screenBox(page); + const before = await designFiles(page, id); + + await drawFrameTool( + page, + "Frame", + { x: screen.x + 40 * screen.scale, y: screen.y + 150 * screen.scale }, + { x: screen.x + 260 * screen.scale, y: screen.y + 400 * screen.scale }, + ); + + expect( + await designFiles(page, id), + "drawing inside a screen must not add a screen file", + ).toEqual(before); + expect( + await fileContent(page, id, "index.html"), + "the frame must land in the screen it was drawn in", + ).toContain('data-an-primitive="frame"'); +}); + +test("1:19 — the Screen tool makes a top-level screen, the Frame tool does not", async ({ + page, +}) => { + const id = await newDesign(page); + await openEditor(page, id); + const empty = await emptyBoardPoint(page); + const before = await designFiles(page, id); + + await drawFrameTool(page, "Screen", empty, { + x: empty.x + 240, + y: empty.y + 260, + }); + + expect( + (await designFiles(page, id)).length, + "a board frame becomes a new screen file", + ).toBe(before.length + 1); +}); + +// boardSurfaceLocalPointToBoardPoint translates the board's 8192² document as +// 1:1 canvas units, so a board-sourced drag's canvas y runs past the target +// screen and getFrameEntryAtPoint never resolves a frame. +test.fixme("4:24 — a board frame can be dragged into a screen and become a child", async ({ + page, +}) => { + const id = await newDesign(page); + await openEditor(page, id); + const empty = await emptyBoardPoint(page); + await drawFrameTool(page, "Frame", empty, { + x: empty.x + 200, + y: empty.y + 200, + }); + + expect( + await fileContent(page, id, "__board__.html"), + "precondition: the frame tool must put a frame on the board", + ).toContain('data-an-primitive="frame"'); + + // Board objects live in their own iframe behind the screens. + const boardFrame = page + .locator("[data-board-surface-layer] iframe") + .first() + .contentFrame() + .locator('[data-an-primitive="frame"]') + .first(); + const from = (await boardFrame.boundingBox())!; + const screen = await screenBox(page); + + await page + .locator('[data-design-bottom-toolbar] button[aria-label="Move"]') + .click(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move( + from.x + from.width / 2 - 12, + from.y + from.height / 2, + { + steps: 4, + }, + ); + await page.mouse.move( + screen.x + screen.width / 2, + screen.y + 300 * screen.scale, + { steps: 24 }, + ); + await page.waitForTimeout(700); + await page.mouse.up(); + await page.waitForTimeout(3000); + + expect( + await fileContent(page, id, "index.html"), + `Clip 4:24 "adding a frame inside of a screen is not possible". Dragging a ` + + `board frame onto a screen must move it into that screen's document.`, + ).toContain('data-an-primitive="frame"'); +}); + +test("2:11 — a shape drawn on the board is not painted behind the screens", async ({ + page, +}) => { + const id = await newDesign(page); + await openEditor(page, id); + const empty = await emptyBoardPoint(page); + await drawWith(page, "Rectangle", empty, { + x: empty.x + 160, + y: empty.y + 120, + }); + + const stacking = await page.evaluate(() => { + const zOf = (el: Element | null) => { + let node = el as HTMLElement | null; + while (node) { + const z = getComputedStyle(node).zIndex; + if (z && z !== "auto") return Number(z); + node = node.parentElement; + } + return 0; + }; + const screenCard = document.querySelector("[data-screen-iframe-id]"); + const boardObject = document.querySelector( + "[data-draft-id],[data-board-primitive-id],[data-an-board-object]", + ); + return boardObject + ? { screen: zOf(screenCard), object: zOf(boardObject) } + : null; + }); + test.skip( + !stacking, + "no board object node was found to compare stacking against", + ); + + expect( + stacking!.object, + `Clip 2:11 "this is now behind the screen. I don't understand why that is." ` + + `A board object must not stack below a screen card ` + + `(object z=${stacking!.object}, screen z=${stacking!.screen}).`, + ).toBeGreaterThanOrEqual(stacking!.screen); +}); + +test("a rectangle drawn on the board keeps its neutral fill", async ({ + page, +}) => { + const id = await newDesign(page); + await openEditor(page, id); + const empty = await emptyBoardPoint(page); + await drawWith(page, "Rectangle", empty, { + x: empty.x + 160, + y: empty.y + 120, + }); + + const style = + /data-an-primitive="rectangle"[^>]*style="([^"]*)"/.exec( + await fileContent(page, id, "__board__.html"), + )?.[1] ?? ""; + expect( + style, + `the clip reports rectangles coming out black; the canonical fill is a ` + + `neutral grey. Got: ${style || "(no rectangle found)"}`, + ).toContain("rgb(218, 218, 218)"); +}); + +test("the canvas does not go black and hide the screens after drawing a frame", async ({ + page, +}) => { + const id = await newDesign(page); + await openEditor(page, id); + const empty = await emptyBoardPoint(page); + + const before = await screenBox(page); + expect( + before.width, + "precondition: the screen renders before drawing", + ).toBeGreaterThan(50); + + await drawFrameTool(page, "Frame", empty, { + x: empty.x + 240, + y: empty.y + 260, + }); + + // Clip "Canvas Turns Black and Hides Frames": after the frame was created + // the overview painted black and every screen vanished from the canvas. + const after = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .boundingBox(); + expect( + after && after.width > 50 && after.height > 50, + `the screen must still be on the canvas after drawing a frame; got ` + + `${after ? `${Math.round(after.width)}x${Math.round(after.height)}` : "no screen"}`, + ).toBe(true); + + const visibleScreens = await page.locator("[data-screen-iframe-id]").count(); + expect( + visibleScreens, + "screens must not be hidden after drawing a frame", + ).toBeGreaterThan(0); +}); diff --git a/templates/design/e2e/global-setup.ts b/templates/design/e2e/global-setup.ts index 4badd93760..12ec0f18e2 100644 --- a/templates/design/e2e/global-setup.ts +++ b/templates/design/e2e/global-setup.ts @@ -173,42 +173,37 @@ export default async function globalSetup(config: FullConfig) { BROWSER_CHANNEL ? { channel: BROWSER_CHANNEL } : {}, ); const context = await browser.newContext(); - const page = await context.newPage(); try { - await page.goto(`${baseURL}/sign-in`, { - waitUntil: "domcontentloaded", - }); - - const isSignIn = async () => /sign in/i.test(await page.title()); - - if (await isSignIn()) { - // Try to create the account; if it already exists, fall back to sign in. - await page.locator("#s-email").fill(E2E_EMAIL); - await page.locator("#s-pass").fill(E2E_PASSWORD); - await page.locator("#s-pass2").fill(E2E_PASSWORD); - await page.locator("#signup-form button[type='submit']").click(); - await page.waitForTimeout(2500); - - if (await isSignIn()) { - // Account exists; switch to the Sign in tab and log in. - await page - .getByRole("button", { name: "Sign in", exact: true }) - .first() - .click() - .catch(() => {}); - await page.locator("#l-email").fill(E2E_EMAIL); - await page.locator("#l-pass").fill(E2E_PASSWORD); - await page.locator("#login-form button[type='submit']").click(); - await page.waitForTimeout(2500); - } + const registration = await context.request.post( + `${baseURL}/_agent-native/auth/register`, + { + data: { + email: E2E_EMAIL, + password: E2E_PASSWORD, + }, + headers: { "Content-Type": "application/json" }, + }, + ); + if (!registration.ok() && registration.status() !== 409) { + throw new Error( + `registration failed: ${registration.status()} ${await registration.text()}`, + ); } - await page - .waitForFunction(() => !/sign in/i.test(document.title), null, { - timeout: 20_000, - }) - .catch(() => {}); + const login = await context.request.post( + `${baseURL}/_agent-native/auth/login`, + { + data: { + email: E2E_EMAIL, + password: E2E_PASSWORD, + }, + headers: { "Content-Type": "application/json" }, + }, + ); + if (!login.ok()) { + throw new Error(`login failed: ${login.status()} ${await login.text()}`); + } await context.storageState({ path: STATE_PATH }); diff --git a/templates/design/e2e/landing-page-authoring.spec.ts b/templates/design/e2e/landing-page-authoring.spec.ts new file mode 100644 index 0000000000..03021d99b3 --- /dev/null +++ b/templates/design/e2e/landing-page-authoring.spec.ts @@ -0,0 +1,685 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +/** + * Each test asserts the Figma-correct outcome, so a failure is the bug report. + * Test names cite clips.agent-native.com/share/jJM4kC0KAkUB. + * + * Do not switch to helpers.ts `selectByText`/`enterDirectMode`: they route + * through the screen card's Interact button, a preview with no edit shield. + */ + +const PAGE_W = 1440; +const PAGE_H = 900; +const MOD = process.platform === "darwin" ? "Meta" : "Control"; + +const BLANK_SCREEN = ` + + Landing + +`; + +interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +let baseURL = ""; +let surfacedErrors: string[] = []; + +async function postAction( + page: Page, + name: string, + input: Record, +): Promise { + const res = await page.request.post( + `${baseURL}/_agent-native/actions/${name}`, + { data: input, headers: { "Content-Type": "application/json" } }, + ); + if (!res.ok()) { + throw new Error( + `${name}: ${res.status()} ${(await res.text()).slice(0, 200)}`, + ); + } + return res.json(); +} + +/** A fresh single-screen design, so every test is independent. */ +async function newDesign(page: Page, content = BLANK_SCREEN): Promise { + const created = await postAction(page, "create-design", { + title: "Landing page authoring (clip repro)", + projectType: "prototype", + }); + const id = created?.id ?? created?.data?.id ?? created?.design?.id; + if (!id) throw new Error("create-design returned no id"); + await postAction(page, "create-file", { + designId: id, + filename: "index.html", + content, + fileType: "html", + }); + return id; +} + +async function indexHtml(page: Page, designId: string): Promise { + const result = await page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${designId}`) + .then((r) => r.json()); + return ( + (result.files ?? []).find((f: any) => f.filename === "index.html") + ?.content ?? "" + ); +} + +function numProp(style: string, prop: string): number { + const raw = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*(-?[\\d.]+)px`, "i").exec( + style, + ); + return raw ? Number(raw[1]) : NaN; +} + +/** Inline styles of every committed primitive of one kind, in document order. */ +function primitiveStyles(html: string, kind: string): string[] { + const re = new RegExp( + `data-an-primitive="${kind}"[^>]*?style="([^"]*)"|style="([^"]*)"[^>]*?data-an-primitive="${kind}"`, + "gi", + ); + const found: string[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(html))) found.push(m[1] ?? m[2] ?? ""); + return found; +} + +function rectFromStyle(style: string): Rect { + return { + left: numProp(style, "left"), + top: numProp(style, "top"), + width: numProp(style, "width"), + height: numProp(style, "height"), + }; +} + +function toolbar(page: Page): Locator { + return page.locator("[data-design-bottom-toolbar]"); +} + +function layersTree(page: Page): Locator { + return page.getByRole("tree", { name: "Layers" }); +} + +function screenCard(page: Page): Locator { + return page.locator("[data-screen-card]").first(); +} + +function inFrame(page: Page, selector: string): Locator { + return page + .frameLocator("iframe[data-design-preview-iframe]") + .first() + .locator(selector); +} + +async function openEditor(page: Page, designId: string): Promise { + await page.goto(`${baseURL}/design/${designId}`, { + waitUntil: "domcontentloaded", + }); + await toolbar(page) + .locator('button[aria-label="Move"]') + .waitFor({ state: "visible", timeout: 45_000 }); + await page + .locator("iframe[data-design-preview-iframe]") + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + await page.waitForTimeout(2500); + await page + .getByRole("button", { name: "Expand layer" }) + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(800); +} + +/** The screen's own content viewport — never assume; it is not the page size. */ +async function contentSize(page: Page): Promise<{ w: number; h: number }> { + return page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => ({ + w: document.documentElement.clientWidth, + h: document.documentElement.clientHeight, + })); +} + +async function toScreenPoint(page: Page, x: number, y: number) { + const card = await screenCard(page).boundingBox(); + if (!card) throw new Error("no screen card on the overview canvas"); + const size = await contentSize(page); + return { + x: card.x + (x / size.w) * card.width, + y: card.y + (y / size.h) * card.height, + }; +} + +async function useTool(page: Page, name: string): Promise { + await toolbar(page).locator(`button[aria-label="${name}"]`).click(); + await expect( + toolbar(page).locator(`button[aria-label="${name}"]`), + ).toHaveAttribute("aria-pressed", "true"); + await page.waitForTimeout(250); +} + +async function dragOnCanvas(page: Page, rect: Rect): Promise { + const a = await toScreenPoint(page, rect.left, rect.top); + const b = await toScreenPoint( + page, + rect.left + rect.width, + rect.top + rect.height, + ); + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + await page.mouse.move(b.x, b.y, { steps: 14 }); + await page.waitForTimeout(200); + await page.mouse.up(); + await page.waitForTimeout(1600); +} + +async function drawRect(page: Page, rect: Rect): Promise { + await useTool(page, "Rectangle"); + await dragOnCanvas(page, rect); +} + +async function drawFrame(page: Page, rect: Rect): Promise { + await useTool(page, "Frame"); + await dragOnCanvas(page, rect); +} + +async function addText( + page: Page, + at: { x: number; y: number }, + text: string, +): Promise { + await useTool(page, "Text"); + const point = await toScreenPoint(page, at.x, at.y); + await page.mouse.click(point.x, point.y); + await page.waitForTimeout(600); + await page.keyboard.press(`${MOD}+A`); + await page.keyboard.type(text, { delay: 12 }); + await page.keyboard.press("Escape"); + await page.waitForTimeout(1600); +} + +async function readToasts(page: Page): Promise { + return page + .locator("[data-sonner-toast], [role='alert']") + .allTextContents() + .then((all) => all.map((t) => t.trim()).filter(Boolean)) + .catch(() => []); +} + +test.use({ viewport: { width: 1600, height: 1000 } }); + +test.beforeEach(async ({ page }, testInfo) => { + baseURL = + (testInfo.project.use.baseURL as string | undefined) ?? + process.env.E2E_BASE_URL ?? + `http://127.0.0.1:${process.env.E2E_PORT ?? 9333}`; + surfacedErrors = []; + page.on("console", (msg) => { + if (msg.type() !== "error") return; + const text = msg.text(); + if (/Outdated Optimize Dep|favicon|React DevTools/i.test(text)) return; + surfacedErrors.push(text.slice(0, 200)); + }); +}); + +// ── Header ──────────────────────────────────────────────────────────────── + +test("0:53 — a frame commits the rectangle you dragged", async ({ page }) => { + const designId = await newDesign(page); + await openEditor(page, designId); + + // Inset from the very top edge so this measures size fidelity, not the + // separate "nothing commits at y=0" case the full-page test covers. + const requested: Rect = { left: 20, top: 40, width: 280, height: 96 }; + await drawFrame(page, requested); + + const styles = primitiveStyles(await indexHtml(page, designId), "frame"); + expect(styles, "the Frame tool committed no frame at all").toHaveLength(1); + const actual = rectFromStyle(styles[0]); + expect( + [actual.left, actual.top, actual.width, actual.height], + `Dragged ${requested.width}x${requested.height} at (${requested.left},${requested.top}); ` + + `committed ${actual.width}x${actual.height} at (${actual.left},${actual.top}). ` + + `Clip 0:53 "it created a longer one".`, + ).toEqual([ + expect.closeTo(requested.left, -1), + expect.closeTo(requested.top, -1), + expect.closeTo(requested.width, -1), + expect.closeTo(requested.height, -1), + ]); +}); + +test("1:16 — header text is readable against the canvas background", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await addText(page, { x: 24, y: 36 }, "Builder.io"); + + const styles = primitiveStyles(await indexHtml(page, designId), "text"); + expect(styles, "the Text tool committed no text").not.toHaveLength(0); + const colour = /(?:^|;)\s*color\s*:\s*([^;]+)/i.exec(styles[0])?.[1]?.trim(); + expect( + colour, + `Text committed color:"${colour}" on a #0b0f19 canvas. currentcolor resolves ` + + `to the UA default black, so the header is invisible. NOT Figma parity — Figma ` + + `defaults to black too; this asserts Design's own intent, since it stamps ` + + `data-an-auto-text-color on every text primitive.`, + ).not.toBe("currentcolor"); +}); + +// ── Foundation ──────────────────────────────────────────────────────────── + +test("6:03 — every shape you draw lands inside the page", async ({ page }) => { + const designId = await newDesign(page); + await openEditor(page, designId); + const requested: Rect = { left: 120, top: 220, width: 1200, height: 420 }; + await drawRect(page, requested); + + const styles = primitiveStyles(await indexHtml(page, designId), "rectangle"); + expect(styles, "the Rectangle tool committed no rectangle").toHaveLength(1); + const hero = rectFromStyle(styles[0]); + expect( + hero.top + hero.height, + `Drew a hero at top=${requested.top} height=${requested.height}; it committed ` + + `top=${hero.top} height=${hero.height}, ending ${hero.top + hero.height}px down a ` + + `${PAGE_H}px page. Clip 6:03 "where are the rectangles?".`, + ).toBeLessThanOrEqual(PAGE_H); + expect( + hero.left + hero.width, + `Hero spans to x=${hero.left + hero.width} in a ${PAGE_W}px page.`, + ).toBeLessThanOrEqual(PAGE_W + 1); +}); + +test("8:35 — a frame adopts an element drawn inside it", async ({ page }) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawFrame(page, { left: 20, top: 150, width: 280, height: 300 }); + await addText(page, { x: 60, y: 260 }, "Design and code, one canvas"); + + const html = await indexHtml(page, designId); + const frameAt = html.indexOf('data-an-primitive="frame"'); + const textAt = html.indexOf('data-an-primitive="text"'); + const frameCloses = html.indexOf("", frameAt); + expect( + textAt > frameAt && textAt < frameCloses, + `Text drawn inside the frame's bounds committed as a sibling, not a child ` + + `(frame at ${frameAt}, closes at ${frameCloses}, text at ${textAt}). ` + + `Frames are the container primitive — clip 8:35.`, + ).toBe(true); +}); + +test("a rectangle does NOT adopt children, matching Figma", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawRect(page, { left: 20, top: 150, width: 280, height: 300 }); + await addText(page, { x: 60, y: 260 }, "Not a child"); + + const html = await indexHtml(page, designId); + const rectAt = html.indexOf('data-an-primitive="rectangle"'); + const textAt = html.indexOf('data-an-primitive="text"'); + const rectCloses = html.indexOf("", rectAt); + expect( + textAt > rectAt && textAt < rectCloses, + "a rectangle is a vector shape and must never become a container", + ).toBe(false); +}); + +test("2:35 — Shift+A turns a selected frame into an auto-layout container", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawFrame(page, { left: 20, top: 120, width: 280, height: 300 }); + await layersTree(page) + .getByRole("treeitem") + .filter({ hasText: "Frame" }) + .first() + .click(); + await page.waitForTimeout(800); + + const before = await indexHtml(page, designId); + await page.keyboard.press("Shift+A"); + await page.waitForTimeout(2500); + const after = await indexHtml(page, designId); + + expect( + after, + `Shift+A on a selected frame left index.html byte-identical. ` + + `Clip 2:35 "should make it auto layout but doesn't".`, + ).not.toBe(before); + expect(primitiveStyles(after, "frame")[0] ?? "").toMatch( + /display\s*:\s*flex/i, + ); +}); + +test("8:09 — enabling auto layout keeps the container's children", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawFrame(page, { left: 20, top: 120, width: 280, height: 300 }); + await addText(page, { x: 50, y: 200 }, "Hero title"); + const before = await indexHtml(page, designId); + const textsBefore = primitiveStyles(before, "text").length; + + await layersTree(page) + .getByRole("treeitem") + .filter({ hasText: "Frame" }) + .first() + .click(); + await page.waitForTimeout(800); + await page.keyboard.press("Shift+A"); + await page.waitForTimeout(2500); + + const after = await indexHtml(page, designId); + test.skip( + after === before, + "Shift+A did not apply auto layout, so there is nothing to drop — see the 2:35 test", + ); + expect( + primitiveStyles(after, "text").length, + `Auto layout dropped text children: ${textsBefore} before, ` + + `${primitiveStyles(after, "text").length} after. ` + + `Clip 8:09 "it has the rectangle but doesn't have title and the description".`, + ).toBe(textsBefore); +}); + +// ── Drag and drop ───────────────────────────────────────────────────────── + +test("3:17 — dragging a layer on the canvas moves it", async ({ page }) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawRect(page, { left: 40, top: 200, width: 200, height: 160 }); + const before = rectFromStyle( + primitiveStyles(await indexHtml(page, designId), "rectangle")[0] ?? "", + ); + + const target = inFrame(page, '[data-an-primitive="rectangle"]').first(); + const box = await target.boundingBox(); + expect(box, "the rectangle has no hit box on the canvas").not.toBeNull(); + await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move( + box!.x + box!.width / 2 + 40, + box!.y + box!.height / 2 + 100, + { steps: 16 }, + ); + await page.waitForTimeout(300); + await page.mouse.up(); + await page.waitForTimeout(2000); + + const after = rectFromStyle( + primitiveStyles(await indexHtml(page, designId), "rectangle")[0] ?? "", + ); + expect( + [after.left, after.top], + `Dragged the rectangle by (40,100); it stayed at (${before.left},${before.top}). ` + + `Clip 3:17 "let me try moving them vertically — doesn't work".`, + ).not.toEqual([before.left, before.top]); +}); + +test("2:59 — moving a layer raises no 'Could not move that layer' toast", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawRect(page, { left: 40, top: 200, width: 200, height: 160 }); + + const target = inFrame(page, '[data-an-primitive="rectangle"]').first(); + const box = await target.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2 + 120, { + steps: 16, + }); + await page.mouse.up(); + await page.waitForTimeout(2000); + } + expect( + (await readToasts(page)).filter((t) => + /could not move that layer/i.test(t), + ), + `Clip 2:59 shows this toast on an ordinary move.`, + ).toHaveLength(0); +}); + +test("5:41 — no internal node-resolution error reaches the user", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawRect(page, { left: 40, top: 200, width: 200, height: 160 }); + await addText(page, { x: 60, y: 260 }, "Drag me"); + + const target = inFrame(page, '[data-an-primitive="text"]').first(); + const box = await target.boundingBox(); + if (box) { + await page.mouse.move(box.x + 10, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + 320, box.y + box.height / 2 + 80, { + steps: 18, + }); + await page.mouse.up(); + await page.waitForTimeout(2500); + } + + const leaked = [...(await readToasts(page)), ...surfacedErrors].filter((t) => + /not found in sourceHtml|data-agent-native-node-id="draft-/i.test(t), + ); + expect( + leaked, + `Clip 5:41 surfaces the raw internal message ` + + `'Node with data-agent-native-node-id="draft-rect-…" not found in sourceHtml'.`, + ).toHaveLength(0); +}); + +const STACK_SCREEN = `Stack + +
+

First paragraph

+

Second paragraph

+
`; + +test("5:07 — a text layer can be reordered by dragging it on the canvas", async ({ + page, +}) => { + // Absolutely-positioned text moves in x/y when dragged, as in Figma; the + // clip's complaint is about reordering a stack, which is the flow path. + const designId = await newDesign(page, STACK_SCREEN); + await openEditor(page, designId); + + const second = (await inFrame( + page, + '[data-agent-native-node-id="p2"]', + ).boundingBox())!; + const first = (await inFrame( + page, + '[data-agent-native-node-id="p1"]', + ).boundingBox())!; + + // The in-iframe "shield" overlay swallows locator clicks — drive the + // pointer directly. + await page.mouse.click( + second.x + second.width / 2, + second.y + second.height / 2, + ); + await page.waitForTimeout(1200); + await page.mouse.move( + second.x + second.width / 2, + second.y + second.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + second.x + second.width / 2, + second.y + second.height / 2 - 10, + { steps: 4 }, + ); + await page.mouse.move(first.x + first.width / 2, first.y + 3, { steps: 20 }); + await page.waitForTimeout(700); + await page.mouse.up(); + await page.waitForTimeout(2500); + + const html = await indexHtml(page, designId); + expect( + html.indexOf("Second paragraph"), + `Dragging "Second paragraph" above "First paragraph" on the canvas did not ` + + `reorder the document. Clip 5:07 "why can't I simply drag and drop a text ` + + `just above a text I want? I need to use this left panel".`, + ).toBeLessThan(html.indexOf("First paragraph")); +}); + +test("5:30 — dragging does not repaint the canvas background", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawRect(page, { left: 40, top: 200, width: 200, height: 160 }); + + const readBackground = () => + inFrame(page, "body").evaluate((b) => getComputedStyle(b).backgroundColor); + const before = await readBackground(); + + const target = inFrame(page, '[data-an-primitive="rectangle"]').first(); + const box = await target.boundingBox(); + if (box) { + await page.mouse.move(box.x + 10, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + 200, box.y + box.height / 2 + 60, { + steps: 16, + }); + await page.mouse.up(); + await page.waitForTimeout(1800); + } + expect( + await readBackground(), + `Clip 5:30 "why did the background become black" mid-drag.`, + ).toBe(before); +}); + +// ── Footer ──────────────────────────────────────────────────────────────── + +test("4:39 — aligning a multi-selection moves every selected layer", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + await drawRect(page, { left: 20, top: 500, width: 130, height: 120 }); + await drawRect(page, { left: 170, top: 560, width: 130, height: 120 }); + + const rows = layersTree(page) + .getByRole("treeitem") + .filter({ hasText: "Rectangle" }); + await rows.nth(0).click(); + await rows.nth(1).click({ modifiers: ["Shift"] }); + await page.waitForTimeout(1000); + await expect( + page.locator('[role="treeitem"][aria-selected="true"]'), + ).toHaveCount(2); + + await page.locator('button[aria-label="Start"]').first().click(); + await page.waitForTimeout(2500); + + const styles = primitiveStyles(await indexHtml(page, designId), "rectangle"); + const tops = styles.map((s) => numProp(s, "top")); + expect( + new Set(tops).size, + `Align-top on a 2-layer selection left them at tops ${JSON.stringify(tops)}. ` + + `Clip 4:39 "why did the alignment only shift this and not this".`, + ).toBe(1); +}); + +test("0:28 — a deleted screen stays deleted", async ({ page }) => { + const designId = await newDesign(page); + await postAction(page, "create-file", { + designId, + filename: "scratch.html", + content: BLANK_SCREEN, + fileType: "html", + }); + await openEditor(page, designId); + + const row = layersTree(page) + .getByRole("treeitem") + .filter({ hasText: "Scratch" }) + .first(); + await row.click(); + await page.waitForTimeout(600); + await page.keyboard.press("Delete"); + await page.waitForTimeout(1500); + // Deleting a whole screen is destructive, so it confirms first. + await page + .getByRole("alertdialog") + .getByRole("button") + .filter({ hasText: /^Delete$/ }) + .first() + .click(); + await page.waitForTimeout(2500); + + const files = await page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${designId}`) + .then((r) => r.json()); + expect( + (files.files ?? []).map((f: any) => f.filename), + `Clip 0:28 "that screen was never deleted, it seems".`, + ).not.toContain("scratch.html"); +}); + +// ── The whole page ──────────────────────────────────────────────────────── + +test("a header + hero + footer landing page renders entirely on the page", async ({ + page, +}) => { + const designId = await newDesign(page); + await openEditor(page, designId); + + await drawFrame(page, { left: 0, top: 0, width: 320, height: 96 }); + await addText(page, { x: 24, y: 36 }, "Builder.io"); + await drawRect(page, { left: 20, top: 220, width: 280, height: 200 }); + await addText(page, { x: 40, y: 300 }, "Ship design and code together"); + await drawRect(page, { left: 20, top: 620, width: 130, height: 140 }); + await drawRect(page, { left: 170, top: 620, width: 130, height: 140 }); + + const painted = await inFrame(page, "[data-an-primitive]").evaluateAll( + (els) => + els.map((el) => { + const r = el.getBoundingClientRect(); + const doc = el.ownerDocument.documentElement; + return { + kind: el.getAttribute("data-an-primitive"), + top: Math.round(r.top), + right: Math.round(r.right), + withinPage: + r.width > 0 && + r.height > 0 && + r.top >= -1 && + r.bottom <= doc.clientHeight + 1 && + r.right <= doc.clientWidth + 1, + }; + }), + ); + const escaped = painted.filter((p) => !p.withinPage); + expect( + escaped, + `${escaped.length} of ${painted.length} layers of the finished landing page fall ` + + `outside the ${PAGE_W}x${PAGE_H} page: ${JSON.stringify(escaped)}`, + ).toEqual([]); +}); diff --git a/templates/design/e2e/structure-selection.spec.ts b/templates/design/e2e/structure-selection.spec.ts new file mode 100644 index 0000000000..9902eecfbc --- /dev/null +++ b/templates/design/e2e/structure-selection.spec.ts @@ -0,0 +1,569 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +/** + * Grouping and selection traversal, asserted against Figma's documented + * behaviour. Doc facts are quoted in each failure message so a reviewer can + * check the claim without trusting the test author. + */ + +const PAGE_W = 1440; +const PAGE_H = 900; +const MOD = process.platform === "darwin" ? "Meta" : "Control"; + +const FIXTURE = ` + + Structure + +
+
+
+
+
+
+
+ +`; + +let baseURL = ""; + +async function postAction( + page: Page, + name: string, + input: Record, +) { + const res = await page.request.post( + `${baseURL}/_agent-native/actions/${name}`, + { + data: input, + headers: { "Content-Type": "application/json" }, + }, + ); + if (!res.ok()) + throw new Error( + `${name}: ${res.status()} ${(await res.text()).slice(0, 200)}`, + ); + return res.json(); +} + +async function newDesign(page: Page): Promise { + const created = await postAction(page, "create-design", { + title: "structure and selection", + projectType: "prototype", + }); + const id = created?.id ?? created?.data?.id; + if (!id) throw new Error("create-design returned no id"); + await postAction(page, "create-file", { + designId: id, + filename: "index.html", + content: FIXTURE, + fileType: "html", + }); + return id; +} + +async function indexHtml(page: Page, designId: string): Promise { + const result = await page.request + .get(`${baseURL}/_agent-native/actions/get-design?id=${designId}`) + .then((r) => r.json()); + return ( + (result.files ?? []).find((f: any) => f.filename === "index.html") + ?.content ?? "" + ); +} + +function styleOf(html: string, id: string): string { + return ( + new RegExp( + `data-agent-native-node-id="${id}"[^>]*?style="([^"]*)"`, + "i", + ).exec(html)?.[1] ?? "" + ); +} + +function styleNum(style: string, prop: string): number { + const m = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*(-?[\\d.]+)px`, "i").exec( + style, + ); + return m ? Number(m[1]) : NaN; +} + +function toolbar(page: Page): Locator { + return page.locator("[data-design-bottom-toolbar]"); +} + +function layersTree(page: Page): Locator { + return page.getByRole("tree", { name: "Layers" }); +} + +function layerRow(page: Page, name: string): Locator { + return layersTree(page) + .getByRole("treeitem") + .filter({ hasText: name }) + .first(); +} + +function node(page: Page, id: string): Locator { + return page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator(`[data-agent-native-node-id="${id}"]`); +} + +async function selectedLayerName(page: Page): Promise { + const text = await page + .locator('[role="treeitem"][aria-selected="true"]') + .first() + .textContent() + .catch(() => null); + return text?.trim() ?? null; +} + +async function openEditor(page: Page, designId: string): Promise { + await page.goto(`${baseURL}/design/${designId}`, { + waitUntil: "domcontentloaded", + }); + await toolbar(page) + .locator('button[aria-label="Move"]') + .waitFor({ timeout: 45_000 }); + await page + .locator("iframe[data-design-preview-iframe]") + .first() + .waitFor({ timeout: 30_000 }); + await page.waitForTimeout(2500); + for (let i = 0; i < 5; i += 1) { + await page + .getByRole("button", { name: "Expand layer" }) + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(250); + } + await page.waitForTimeout(500); +} + +async function scale(page: Page): Promise { + const card = await page.locator("[data-screen-card]").first().boundingBox(); + if (!card) throw new Error("no screen card"); + // Never assume the page size — the screen's own viewport is the truth. + const inner = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator("body") + .evaluate(() => document.documentElement.clientWidth); + return card.width / inner; +} + +async function selectViaTree(page: Page, name: string): Promise { + await layerRow(page, name).click(); + await page.waitForTimeout(1500); +} + +async function multiSelect(page: Page, names: string[]): Promise { + await layerRow(page, names[0]).click(); + await page.waitForTimeout(700); + for (const name of names.slice(1)) { + await layerRow(page, name).click({ modifiers: ["Shift"] }); + await page.waitForTimeout(700); + } +} + +test.use({ viewport: { width: 1600, height: 1000 } }); + +test.beforeEach(async ({ page }, testInfo) => { + baseURL = + (testInfo.project.use.baseURL as string | undefined) ?? + process.env.E2E_BASE_URL ?? + `http://127.0.0.1:${process.env.E2E_PORT ?? 9333}`; +}); + +test.describe("keyboard selection traversal", () => { + test("Enter selects a child of the current selection", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Wrap"); + await page.keyboard.press("Enter"); + await page.waitForTimeout(1500); + const name = await selectedLayerName(page); + expect( + name, + `Figma: "You can double-click on the object or press the enter key to select one ` + + `level of nesting down." Selection stayed on "${name}".`, + ).toMatch(/Kid/); + }); + + test("Escape selects the parent of the current selection", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Kid One"); + await page.keyboard.press("Escape"); + await page.waitForTimeout(1500); + const name = await selectedLayerName(page); + expect( + name, + `Escape should walk up to the parent; selection is "${name}".`, + ).toBe("Wrap"); + }); + + test("Tab selects the next sibling", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Kid One"); + await page.keyboard.press("Tab"); + await page.waitForTimeout(1500); + const name = await selectedLayerName(page); + expect( + name, + `Figma: "Press the Tab key to select the next sibling". Selection is "${name}".`, + ).toBe("Kid Two"); + }); + + test("Shift+Tab selects the previous sibling", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Kid Two"); + await page.keyboard.press("Shift+Tab"); + await page.waitForTimeout(1500); + const name = await selectedLayerName(page); + expect( + name, + `Figma: "Shift + Tab to select the previous sibling". Selection is "${name}".`, + ).toBe("Kid One"); + }); + + test("Shift+Arrow nudges a collapsed container 10px on the first press", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + const row = layerRow(page, "Wrap"); + const collapse = row.getByRole("button", { name: "Collapse layer" }); + if (await collapse.isVisible()) await collapse.click(); + await expect( + row.getByRole("button", { name: "Expand layer" }), + ).toBeVisible(); + await row.click(); + const before = styleNum(styleOf(await indexHtml(page, id), "wrap"), "left"); + + await page.keyboard.press("Shift+ArrowRight"); + + await expect + .poll(() => + indexHtml(page, id).then((html) => + styleNum(styleOf(html, "wrap"), "left"), + ), + ) + .toBe(before + 10); + await expect( + row.getByRole("button", { name: "Expand layer" }), + ).toBeVisible(); + }); + + test("double-clicking an object drills one level down", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + const box = (await node(page, "kid-1").boundingBox())!; + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await page.waitForTimeout(1200); + await page.mouse.dblclick(box.x + box.width / 2, box.y + box.height / 2); + await page.waitForTimeout(1500); + const name = await selectedLayerName(page); + expect( + name, + `double-click should drill into the child; selection is "${name}".`, + ).toMatch(/Kid One/); + }); +}); + +test.describe("groups", () => { + test("Cmd+G wraps the selection in a group layer", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await multiSelect(page, ["Loose A", "Loose B"]); + await page.keyboard.press(`${MOD}+g`); + await page.waitForTimeout(2500); + await expect( + layersTree(page).getByRole("treeitem").filter({ hasText: "Group" }), + "Cmd+G produced no Group layer", + ).toHaveCount(1); + }); + + test("a group's bounds fit its contents", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await multiSelect(page, ["Loose A", "Loose B"]); + await page.keyboard.press(`${MOD}+g`); + await page.waitForTimeout(2500); + + const a = (await node(page, "loose-a").boundingBox())!; + const b = (await node(page, "loose-b").boundingBox())!; + const group = await page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame() + .locator('[data-agent-native-layer-name="Group"]') + .first() + .boundingBox() + .catch(() => null); + expect(group, "no Group element rendered in the preview").not.toBeNull(); + + const expectedWidth = b.x + b.width - a.x; + expect( + group!.width, + `Figma: "Groups automatically adjust their bounds to fit the layers within." ` + + `Children span ${Math.round(expectedWidth)}px; the group measures ${Math.round(group!.width)}px.`, + ).toBeCloseTo(expectedWidth, -1.4); + }); + + test("Cmd+Shift+G ungroups", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await multiSelect(page, ["Loose A", "Loose B"]); + await page.keyboard.press(`${MOD}+g`); + await page.waitForTimeout(2500); + await layerRow(page, "Group").click(); + await page.waitForTimeout(1200); + await page.keyboard.press(`${MOD}+Shift+g`); + await page.waitForTimeout(2500); + await expect( + layersTree(page).getByRole("treeitem").filter({ hasText: "Group" }), + "Cmd+Shift+G left the Group layer in place", + ).toHaveCount(0); + }); + + test("moving a group moves every child by the same delta", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await multiSelect(page, ["Loose A", "Loose B"]); + await page.keyboard.press(`${MOD}+g`); + await page.waitForTimeout(2500); + + const before = await indexHtml(page, id); + const aBefore = styleNum(styleOf(before, "loose-a"), "left"); + const bBefore = styleNum(styleOf(before, "loose-b"), "left"); + const renderedBefore = [ + (await node(page, "loose-a").boundingBox())!.x, + (await node(page, "loose-b").boundingBox())!.x, + ]; + + await layerRow(page, "Group").click(); + await page.waitForTimeout(1200); + for (let i = 0; i < 5; i += 1) { + await page.keyboard.press("Shift+ArrowRight"); + await page.waitForTimeout(400); + } + await page.waitForTimeout(2000); + + // Figma moves the group container and leaves each child's own offset + // alone, so the source offsets must not move while the paint does. + const after = await indexHtml(page, id); + const sourceDeltas = [ + styleNum(styleOf(after, "loose-a"), "left") - aBefore, + styleNum(styleOf(after, "loose-b"), "left") - bBefore, + ]; + const renderedDeltas = [ + (await node(page, "loose-a").boundingBox())!.x - renderedBefore[0], + (await node(page, "loose-b").boundingBox())!.x - renderedBefore[1], + ]; + expect( + sourceDeltas, + `a group nudge must move the container, not rewrite each child's offset`, + ).toEqual([0, 0]); + expect( + Math.abs(renderedDeltas[0] - renderedDeltas[1]), + `both children must move together; they moved ${JSON.stringify(renderedDeltas)}`, + ).toBeLessThan(1); + expect( + renderedDeltas[0], + `5 big nudges must move the group right on screen; moved ${renderedDeltas[0]}`, + ).toBeGreaterThan(0); + }); +}); + +test.describe("multi-selection", () => { + test("dragging a two-element selection moves both by the same delta", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await multiSelect(page, ["Loose A", "Loose B"]); + + const before = await indexHtml(page, id); + const aBefore = styleNum(styleOf(before, "loose-a"), "left"); + const bBefore = styleNum(styleOf(before, "loose-b"), "left"); + + const s = await scale(page); + const box = (await node(page, "loose-a").boundingBox())!; + // Figma snaps to alignment guides unless the primary modifier is held, + // so an unmodified drag legitimately lands within a few px of the ask. + await page.keyboard.down(MOD === "Meta" ? "Meta" : "Control"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move( + box.x + box.width / 2 + 100 * s, + box.y + box.height / 2, + { steps: 16 }, + ); + await page.waitForTimeout(300); + await page.mouse.up(); + await page.keyboard.up(MOD === "Meta" ? "Meta" : "Control"); + await page.waitForTimeout(2200); + + const after = await indexHtml(page, id); + const aDelta = styleNum(styleOf(after, "loose-a"), "left") - aBefore; + const bDelta = styleNum(styleOf(after, "loose-b"), "left") - bBefore; + expect( + aDelta, + `a multi-selection must move as one; A moved ${aDelta}, B moved ${bDelta}`, + ).toBe(bDelta); + expect( + aDelta, + `a 100px drag must not move the selection backwards or nowhere`, + ).toBeGreaterThan(0); + }); + + test("a drag lands the object where the cursor landed", async ({ page }) => { + const id = await newDesign(page); + await openEditor(page, id); + await selectViaTree(page, "Loose A"); + // Selecting in the tree can pan the canvas; measure after it settles or + // the drag starts from stale coordinates. + await page.waitForTimeout(1500); + + const before = await indexHtml(page, id); + const aBefore = styleNum(styleOf(before, "loose-a"), "top"); + const s = await scale(page); + const box = (await node(page, "loose-a").boundingBox())!; + // Drag UP into empty space: moving right would land on Loose B and the + // drop nests instead of translating. + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move( + box.x + box.width / 2, + box.y + box.height / 2 - 100 * s, + { steps: 16 }, + ); + await page.waitForTimeout(300); + await page.mouse.up(); + await page.waitForTimeout(2200); + + const aDelta = + aBefore - styleNum(styleOf(await indexHtml(page, id), "loose-a"), "top"); + // Snapping is on, so the drop is pulled up to SNAP_THRESHOLD_PX (6) onto + // an alignment guide. Cmd is Figma's snap bypass but is overloaded with + // deep-select here, so an exact-delta drag is not expressible. + expect( + Math.abs(100 - aDelta), + `dragging 100px up landed ${aDelta}, which is further than the 6px snap ` + + `threshold can account for`, + ).toBeLessThanOrEqual(6); + }); + + test("a multi-selection shows one combined bounding box", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await multiSelect(page, ["Loose A", "Loose B"]); + await page.waitForTimeout(800); + + // The element selection chrome is painted INSIDE the preview iframe; + // [data-resize-handle] in the host is the screen's own board chrome. + const preview = page + .locator("iframe[data-design-preview-iframe]") + .first() + .contentFrame(); + const measured = await preview.locator("body").evaluate(() => { + const union = ["loose-a", "loose-b"] + .map((id) => + document + .querySelector(`[data-agent-native-node-id="${id}"]`) + ?.getBoundingClientRect(), + ) + .filter((rect): rect is DOMRect => Boolean(rect)); + if (union.length !== 2) return null; + const box = document.querySelector( + "[data-agent-native-multi-selection-bounds]", + ); + if (!box) return null; + const chrome = box.getBoundingClientRect(); + return { + contentWidth: + Math.max(...union.map((r) => r.right)) - + Math.min(...union.map((r) => r.left)), + chromeWidth: chrome.width, + }; + }); + + expect( + measured, + "no multi-selection chrome inside the preview", + ).not.toBeNull(); + expect( + measured!.chromeWidth, + `two boxes spanning ${Math.round(measured!.contentWidth)}px are enclosed ` + + `by ${Math.round(measured!.chromeWidth)}px of chrome`, + ).toBeCloseTo(measured!.contentWidth, -1); + }); + + test("Smart selection exposes spacing handles for evenly spaced layers", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + await multiSelect(page, ["Kid One", "Kid Two", "Kid Three"]); + const box = (await node(page, "kid-2").boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.waitForTimeout(1200); + + const handles = await page.evaluate( + () => + document.querySelectorAll( + "[data-smart-selection],[data-spacing-handle],[data-smart-handle]", + ).length, + ); + expect( + handles, + `Figma: three evenly spaced layers get "a pink ring in the center" of each plus ` + + `"additional pink handles ... between each layer" for spacing. None appeared.`, + ).toBeGreaterThan(0); + }); +}); + +test.describe("frames versus groups", () => { + test("a frame keeps its explicit size when a child moves", async ({ + page, + }) => { + const id = await newDesign(page); + await openEditor(page, id); + const before = styleOf(await indexHtml(page, id), "wrap"); + const wBefore = styleNum(before, "width"); + const hBefore = styleNum(before, "height"); + + await selectViaTree(page, "Kid One"); + for (let i = 0; i < 3; i += 1) { + await page.keyboard.press("Shift+ArrowRight"); + await page.waitForTimeout(200); + } + await page.waitForTimeout(2000); + + const after = styleOf(await indexHtml(page, id), "wrap"); + expect( + [styleNum(after, "width"), styleNum(after, "height")], + `Figma: "frames are layers whose size is explicitly set by you" — moving a child ` + + `must not resize the frame. It went ${wBefore}x${hBefore} → ` + + `${styleNum(after, "width")}x${styleNum(after, "height")}.`, + ).toEqual([wBefore, hBefore]); + }); +}); diff --git a/templates/design/playwright.config.ts b/templates/design/playwright.config.ts index ecc32a9158..a2a2bcdbd0 100644 --- a/templates/design/playwright.config.ts +++ b/templates/design/playwright.config.ts @@ -18,11 +18,9 @@ const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; const AUTH_DIR = process.env.E2E_AUTH_DIR ? path.resolve(process.env.E2E_AUTH_DIR) : path.join(import.meta.dirname, "e2e", ".auth"); -const E2E_DATABASE_URL = `file:${path.join( - import.meta.dirname, - "data", - "e2e.db", -)}`; +const E2E_DATABASE_URL = + process.env.E2E_DATABASE_URL ?? + `file:${path.join(import.meta.dirname, "data", "e2e.db")}`; const BROWSER_CHANNEL = process.env.E2E_BROWSER_CHANNEL; export default defineConfig({ diff --git a/templates/design/shared/breakpoint-media.spec.ts b/templates/design/shared/breakpoint-media.spec.ts index 61e5136f3c..bc7a771352 100644 --- a/templates/design/shared/breakpoint-media.spec.ts +++ b/templates/design/shared/breakpoint-media.spec.ts @@ -41,6 +41,14 @@ describe("managed block extraction / injection", () => { const removed = injectManagedBreakpointCss(withBlock, ""); expect(removed).not.toContain("data-agent-native-breakpoints"); }); + + it("keeps the block inside when the document has no ", () => { + const headless = `

hi

`; + const html = injectManagedBreakpointCss(headless, "/* x */"); + expect(html.indexOf("")); + expect(extractManagedBreakpointCss(html)).toBe("/* x */"); + }); }); describe("CSS body parse / serialize round-trip", () => { diff --git a/templates/design/shared/breakpoint-media.ts b/templates/design/shared/breakpoint-media.ts index 63a878855a..d1909cc594 100644 --- a/templates/design/shared/breakpoint-media.ts +++ b/templates/design/shared/breakpoint-media.ts @@ -200,8 +200,8 @@ export function extractManagedBreakpointCss(html: string): string | null { /** * Inject or replace the managed block. Inserts before `` when no - * managed block exists, or at the top of the document when there is no - * ``. Passing empty CSS removes the block entirely. + * managed block exists, or just inside `` when there is no ``. + * Passing empty CSS removes the block entirely. */ export function injectManagedBreakpointCss(html: string, css: string): string { const openMatch = OPEN_RE.exec(html); @@ -232,6 +232,13 @@ export function injectManagedBreakpointCss(html: string, css: string): string { if (headClose !== -1) { return html.slice(0, headClose) + block + "\n" + html.slice(headClose); } + // Prepending would put the block outside ``, which the design HTML + // integrity check rejects — discarding the whole write. + const htmlOpen = /]*>/i.exec(html); + if (htmlOpen) { + const afterOpen = htmlOpen.index + htmlOpen[0].length; + return html.slice(0, afterOpen) + "\n" + block + html.slice(afterOpen); + } return block + "\n" + html; }