` wrapping its members in the DOM.
+// Wrapping rebases each member's left/top so its absolute position is unchanged:
+// the wrapper sits at the selection bbox top-left, and each child's new left/top
+// is its old left/top minus the wrapper origin (computed client-side, where live
+// layout is available, and passed in via `rebases`). GSAP x/y, CSS translate and
+// --hf-studio-offset vars are deltas relative to flow position and stay untouched.
+
+export interface WrapElementsResult {
+ html: string;
+ matched: boolean;
+ groupId: string | null;
+ error?: string;
+}
+
+export interface UnwrapElementsResult {
+ html: string;
+ unwrapped: boolean;
+}
+
+export interface ElementRebase {
+ target: SourceMutationTarget;
+ left: number;
+ top: number;
+}
+
+function getInlineStylePx(el: Element, property: string): number {
+ const style = (isHTMLElement(el) ? el.getAttribute("style") : null) ?? "";
+ const { props } = parseStyleDecls(style);
+ const raw = props.get(property);
+ if (!raw) return 0;
+ const n = parseFloat(raw);
+ return Number.isFinite(n) ? n : 0;
+}
+
+function setInlineLeftTop(el: HTMLElement, left: number, top: number): void {
+ let style = el.getAttribute("style") ?? "";
+ style = patchStyleAttrString(style, "left", `${left}px`);
+ style = patchStyleAttrString(style, "top", `${top}px`);
+ el.setAttribute("style", style);
+}
+
+// Slug the group name ("Group 1" → "group-1") into a unique, valid element id.
+function uniqueGroupDomId(document: Document, groupId: string): string {
+ const base =
+ groupId
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "group";
+ let id = base;
+ let n = 2;
+ while (document.getElementById(id)) {
+ id = `${base}-${n}`;
+ n += 1;
+ }
+ return id;
+}
+
+// fallow-ignore-next-line complexity
+export function wrapElementsInHtml(
+ source: string,
+ targets: SourceMutationTarget[],
+ groupId: string,
+ bbox: { left: number; top: number; width: number; height: number },
+ rebases: ElementRebase[],
+): WrapElementsResult {
+ const { document, wrappedFragment } = parseSourceDocument(source);
+ if (targets.length === 0) {
+ return { html: source, matched: false, groupId: null, error: "no targets" };
+ }
+
+ // Resolve + dedupe by element ref (two targets may point at the same node).
+ const els: HTMLElement[] = [];
+ const seen = new Set();
+ for (const target of targets) {
+ const el = findTargetElement(document, target);
+ if (!el || !isHTMLElement(el) || seen.has(el)) continue;
+ seen.add(el);
+ els.push(el);
+ }
+ if (els.length === 0) {
+ return { html: source, matched: false, groupId: null, error: "no targets matched" };
+ }
+
+ // P1: require a single common parent (LCA multi-parent wrapping is P2).
+ const parent = els[0]?.parentElement;
+ if (!parent || els.some((el) => el.parentElement !== parent)) {
+ return {
+ html: source,
+ matched: false,
+ groupId: null,
+ error: "grouped elements must share a single parent",
+ };
+ }
+
+ // Order members by their position in the parent (= z-order / stacking order).
+ const memberSet = new Set(els);
+ const ordered = Array.from(parent.children).filter((c): c is HTMLElement => memberSet.has(c));
+
+ // Map each member to its rebased left/top (resolved against the same document).
+ const rebaseByEl = new Map();
+ for (const rebase of rebases) {
+ const el = findTargetElement(document, rebase.target);
+ if (el) rebaseByEl.set(el, { left: rebase.left, top: rebase.top });
+ }
+
+ const wrapper = document.createElement("div");
+ wrapper.setAttribute("data-hf-group", groupId);
+ // A real `id` (slug of the group name) makes the wrapper a first-class node in the
+ // clip manifest / timeline parent-map (both keyed by id) and a clean GSAP target —
+ // without it the wrapper is invisible to the timeline and breaks child enumeration.
+ wrapper.setAttribute("id", uniqueGroupDomId(document, groupId));
+ // Adopt the topmost member's stacking level. A group is one stacking unit, so a
+ // non-member interleaved between two selected members can't stay "between" them
+ // once they unify. Matching Figma/Sketch, the group lifts to the topmost selected
+ // layer: the wrapper goes at the LAST member's slot and carries the max member
+ // z-index — so an interleaved non-member falls below the group instead of hoisting
+ // above it, and explicit member z-indexes are honored.
+ const memberZIndexes = ordered
+ .map((el) =>
+ Number.parseInt(
+ parseStyleDecls(el.getAttribute("style") ?? "").props.get("z-index") ?? "",
+ 10,
+ ),
+ )
+ .filter((z) => Number.isFinite(z));
+ const maxZ = memberZIndexes.length > 0 ? Math.max(...memberZIndexes) : null;
+ wrapper.setAttribute(
+ "style",
+ `position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px` +
+ (maxZ !== null ? `; z-index: ${maxZ}` : ""),
+ );
+
+ // Insert the wrapper at the topmost member's slot, then move members into it.
+ parent.insertBefore(wrapper, ordered[ordered.length - 1] ?? null);
+ for (const el of ordered) {
+ const rebase = rebaseByEl.get(el);
+ if (rebase) setInlineLeftTop(el, rebase.left, rebase.top);
+ wrapper.appendChild(el); // appendChild moves the node, preserving order
+ }
+
+ return {
+ html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
+ matched: true,
+ groupId,
+ };
+}
+
+export function unwrapElementsFromHtml(
+ source: string,
+ groupTarget: SourceMutationTarget,
+): UnwrapElementsResult {
+ const { document, wrappedFragment } = parseSourceDocument(source);
+ const group = findTargetElement(document, groupTarget);
+ if (!group || !isHTMLElement(group)) return { html: source, unwrapped: false };
+ // Shape guard mirroring the wrap-side contract: only ever dissolve an actual
+ // group wrapper. A stale/desynced selection that resolves to a plain
+ // would otherwise be unwrapped — rebasing its children by the parent's origin
+ // (silent corruption). Wrap enforces invariants; unwrap must too.
+ if (!group.hasAttribute("data-hf-group")) return { html: source, unwrapped: false };
+
+ const parent = group.parentElement;
+ if (!parent) return { html: source, unwrapped: false };
+
+ // Undo the rebase: child absolute position = child (rebased) + wrapper origin.
+ const wLeft = getInlineStylePx(group, "left");
+ const wTop = getInlineStylePx(group, "top");
+
+ // Move children back to the wrapper's slot, preserving order.
+ for (const child of Array.from(group.children)) {
+ if (isHTMLElement(child)) {
+ setInlineLeftTop(
+ child,
+ getInlineStylePx(child, "left") + wLeft,
+ getInlineStylePx(child, "top") + wTop,
+ );
+ }
+ parent.insertBefore(child, group);
+ }
+ group.remove();
+
+ return {
+ html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
+ unwrapped: true,
+ };
+}
diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts
index c628d49c6e..8a0d35face 100644
--- a/packages/studio-server/src/routes/files.ts
+++ b/packages/studio-server/src/routes/files.ts
@@ -56,8 +56,11 @@ import {
patchElementInHtml,
probeElementInSource,
splitElementInHtml,
+ wrapElementsInHtml,
+ unwrapElementsFromHtml,
isHTMLElement,
type PatchOperation,
+ type ElementRebase,
} from "../helpers/sourceMutation.js";
import { parseHTML } from "linkedom";
@@ -1560,6 +1563,102 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
});
+ api.post("/projects/:id/file-mutations/wrap-elements/*", async (c) => {
+ const ctx = await resolveFileMutationContext(c, adapter, "wrap-elements");
+ if ("error" in ctx) return ctx.error;
+
+ const body = (await c.req.json().catch(() => null)) as {
+ targets?: MutationTarget[];
+ groupId?: string;
+ bbox?: { left?: number; top?: number; width?: number; height?: number };
+ rebases?: ElementRebase[];
+ } | null;
+ if (!Array.isArray(body?.targets) || body.targets.length === 0 || !body.groupId) {
+ return c.json({ error: "targets and groupId required" }, 400);
+ }
+ // left/top/width/height are interpolated into inline style strings; reject
+ // anything non-numeric so a crafted value can't inject extra declarations.
+ const bbox = body.bbox ?? {};
+ const bboxNums = [bbox.left, bbox.top, bbox.width, bbox.height];
+ const rebases = body.rebases ?? [];
+ const allNumeric =
+ bboxNums.every((n) => typeof n === "number" && Number.isFinite(n)) &&
+ rebases.every(
+ (r) =>
+ typeof r?.left === "number" &&
+ Number.isFinite(r.left) &&
+ typeof r?.top === "number" &&
+ Number.isFinite(r.top),
+ );
+ if (!allNumeric) {
+ return c.json({ error: "bbox and rebase coordinates must be finite numbers" }, 400);
+ }
+
+ let originalContent: string;
+ try {
+ originalContent = readFileSync(ctx.absPath, "utf-8");
+ } catch {
+ return c.json({ error: "not found" }, 404);
+ }
+ const result = wrapElementsInHtml(
+ originalContent,
+ body.targets,
+ body.groupId,
+ { left: bbox.left!, top: bbox.top!, width: bbox.width!, height: bbox.height! },
+ rebases,
+ );
+ if (!result.matched) {
+ return c.json(
+ {
+ ok: false,
+ changed: false,
+ content: originalContent,
+ path: ctx.filePath,
+ error: result.error,
+ },
+ result.error === "grouped elements must share a single parent" ? 422 : 400,
+ );
+ }
+ const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
+ if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
+ writeFileSync(ctx.absPath, result.html, "utf-8");
+ return c.json({
+ ok: true,
+ changed: true,
+ groupId: result.groupId,
+ content: result.html,
+ path: ctx.filePath,
+ backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
+ });
+ });
+
+ api.post("/projects/:id/file-mutations/unwrap-elements/*", async (c) => {
+ const ctx = await resolveFileMutationContext(c, adapter, "unwrap-elements");
+ if ("error" in ctx) return ctx.error;
+
+ const parsed = await parseMutationBody<{ target?: MutationTarget }>(c);
+ if ("error" in parsed) return parsed.error;
+
+ let originalContent: string;
+ try {
+ originalContent = readFileSync(ctx.absPath, "utf-8");
+ } catch {
+ return c.json({ error: "not found" }, 404);
+ }
+ const result = unwrapElementsFromHtml(originalContent, parsed.target);
+ if (!result.unwrapped) {
+ return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
+ }
+ return writeIfChanged(
+ c,
+ ctx.project.dir,
+ ctx.filePath,
+ ctx.absPath,
+ originalContent,
+ result.html,
+ );
+ });
+
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "probe-element");
if ("error" in ctx) return ctx.error;
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index d3c761a7a9..fe98759701 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -258,6 +258,8 @@ export function StudioApp() {
onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
+ onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(),
+ onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
activeCompPath,
forceReloadSdkSession: sdkHandle.forceReload,
onToggleRecording: STUDIO_KEYFRAMES_ENABLED
diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx
index 07df9b84fa..5c0c8701e2 100644
--- a/packages/studio/src/components/StudioRightPanel.tsx
+++ b/packages/studio/src/components/StudioRightPanel.tsx
@@ -93,6 +93,7 @@ export function StudioRightPanel({
domEditGroupSelections,
copiedAgentPrompt,
clearDomSelection,
+ handleUngroupSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
@@ -241,6 +242,7 @@ export function StudioRightPanel({
multiSelectCount={domEditGroupSelections.length}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
+ onUngroup={handleUngroupSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetAttributeLive={handleDomAttributeLiveCommit}
diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index bc1394afaa..f0861be649 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -3,7 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { type DomEditSelection } from "./domEditing";
import { useMarqueeGestures } from "./marqueeCommit";
import { MarqueeOverlay } from "./MarqueeOverlay";
-import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
+import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import { collectDomEditLayerItems } from "./domEditingLayers";
import { isElementComputedVisible } from "./domEditingElement";
import {
@@ -248,7 +248,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const elMap = new Map();
for (const item of items) {
if (!isElementComputedVisible(item.element)) continue;
- const r = toOverlayRect(overlay, iframe, item.element);
+ // Groups use their members' union (where they actually render), so a group
+ // whose members sit inside the canvas isn't flagged off-canvas by a stale
+ // wrapper box.
+ const r = groupAwareOverlayRect(overlay, iframe, item.element);
if (!r) continue;
// Any edge crossing the composition border → gray-zone indicator (the
// in-canvas portion is clipped away below, so only the sliver shows).
diff --git a/packages/studio/src/components/editor/InspectorHeaderActions.tsx b/packages/studio/src/components/editor/InspectorHeaderActions.tsx
new file mode 100644
index 0000000000..a8ea815020
--- /dev/null
+++ b/packages/studio/src/components/editor/InspectorHeaderActions.tsx
@@ -0,0 +1,62 @@
+import { X } from "../../icons/SystemIcons";
+import type { DomEditSelection } from "./domEditingTypes";
+
+/** The action buttons in the inspector header: Ungroup (groups only), copy, clear. */
+export function InspectorHeaderActions({
+ element,
+ copied,
+ onCopy,
+ onClear,
+ onUngroup,
+}: {
+ element: DomEditSelection;
+ copied: boolean;
+ onCopy: () => void;
+ onClear: () => void;
+ onUngroup?: () => void;
+}) {
+ return (
+