diff --git a/e2e-tests/fixtures/engine/basic.md b/e2e-tests/fixtures/engine/basic.md
new file mode 100644
index 0000000..668dfa6
--- /dev/null
+++ b/e2e-tests/fixtures/engine/basic.md
@@ -0,0 +1 @@
+This is a simple basic response
diff --git a/e2e-tests/snapshots/visual_editing.spec.ts_visual-editing-single-component-margin.txt b/e2e-tests/snapshots/visual_editing.spec.ts_visual-editing-single-component-margin.txt
new file mode 100644
index 0000000..412ed32
--- /dev/null
+++ b/e2e-tests/snapshots/visual_editing.spec.ts_visual-editing-single-component-margin.txt
@@ -0,0 +1,18 @@
+=== src/pages/Index.tsx ===
+// Update this page (the content is just a fallback if you fail to update the page)
+import { MadeWithDyad } from "@/components/made-with-dyad";
+
+const Index = () => {
+ return (
+
+
+
Welcome to Your Blank App
+
Start building your amazing project here!
+
+
+
+
+ );
+};
+
+export default Index;
\ No newline at end of file
diff --git a/e2e-tests/snapshots/visual_editing.spec.ts_visual-editing-text-content.txt b/e2e-tests/snapshots/visual_editing.spec.ts_visual-editing-text-content.txt
new file mode 100644
index 0000000..691a2fd
--- /dev/null
+++ b/e2e-tests/snapshots/visual_editing.spec.ts_visual-editing-text-content.txt
@@ -0,0 +1,18 @@
+=== src/pages/Index.tsx ===
+// Update this page (the content is just a fallback if you fail to update the page)
+import { MadeWithDyad } from "@/components/made-with-dyad";
+
+const Index = () => {
+ return (
+
+
+
Hello from E2E Test
+
Start building your amazing project here!
+
+
+
+
+ );
+};
+
+export default Index;
\ No newline at end of file
diff --git a/e2e-tests/visual_editing.spec.ts b/e2e-tests/visual_editing.spec.ts
new file mode 100644
index 0000000..cbf25a5
--- /dev/null
+++ b/e2e-tests/visual_editing.spec.ts
@@ -0,0 +1,219 @@
+import { expect } from "@playwright/test";
+import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
+const fs = require("fs");
+const path = require("path");
+
+testSkipIfWindows("edit style of one selected component", async ({ po }) => {
+ await po.setUpDyadPro();
+ await po.sendPrompt("tc=basic");
+ await po.clickTogglePreviewPanel();
+ await po.clickPreviewPickElement();
+
+ // Select a component
+ await po
+ .getPreviewIframeElement()
+ .contentFrame()
+ .getByRole("heading", { name: "Welcome to Your Blank App" })
+ .click();
+
+ // Wait for the toolbar to appear (check for the Margin button which is always visible)
+ const marginButton = po.page.getByRole("button", { name: "Margin" });
+ await expect(marginButton).toBeVisible({
+ timeout: Timeout.MEDIUM,
+ });
+
+ // Ensure the toolbar has proper coordinates before clicking
+ await expect(async () => {
+ const box = await marginButton.boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.y).toBeGreaterThan(0);
+ }).toPass({ timeout: Timeout.MEDIUM });
+
+ // Click on margin button to open the margin popover
+ await marginButton.click();
+
+ // Wait for the popover to fully open by checking for the popover content container
+ const marginDialog = po.page
+ .locator('[role="dialog"]')
+ .filter({ hasText: "Margin" });
+ await expect(marginDialog).toBeVisible({
+ timeout: Timeout.LONG,
+ });
+
+ // Edit margin - set horizontal margin
+ const marginXInput = po.page.getByLabel("Horizontal");
+ await marginXInput.fill("20");
+
+ // Edit margin - set vertical margin
+ const marginYInput = po.page.getByLabel("Vertical");
+ await marginYInput.fill("10");
+
+ // Close the popover by clicking outside or pressing escape
+ await po.page.keyboard.press("Escape");
+
+ // Check if the changes are applied to UI by verifying the visual changes dialog appears
+ await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
+ timeout: Timeout.MEDIUM,
+ });
+
+ // Save the changes
+ await po.page.getByRole("button", { name: "Save Changes" }).click();
+
+ // Wait for the success toast
+ await po.waitForToastWithText("Visual changes saved to source files");
+
+ // Verify that the changes are applied to codebase
+ await po.snapshotAppFiles({
+ name: "visual-editing-single-component-margin",
+ files: ["src/pages/Index.tsx"],
+ });
+});
+
+testSkipIfWindows("edit text of the selected component", async ({ po }) => {
+ await po.setUpDyadPro();
+ await po.sendPrompt("tc=basic");
+ await po.clickTogglePreviewPanel();
+ await po.clickPreviewPickElement();
+
+ // Click on component that contains static text
+ await po
+ .getPreviewIframeElement()
+ .contentFrame()
+ .getByRole("heading", { name: "Welcome to Your Blank App" })
+ .click();
+
+ // Wait for the toolbar to appear (check for the Margin button which is always visible)
+ await expect(po.page.getByRole("button", { name: "Margin" })).toBeVisible({
+ timeout: Timeout.MEDIUM,
+ });
+
+ // Get the iframe and access the content
+ const iframe = po.getPreviewIframeElement();
+ const frame = iframe.contentFrame();
+
+ // Find the heading element in the iframe
+ const heading = frame.getByRole("heading", {
+ name: "Welcome to Your Blank App",
+ });
+
+ await heading.dblclick();
+
+ // Wait for contentEditable to be enabled
+ await expect(async () => {
+ const isEditable = await heading.evaluate(
+ (el) => (el as HTMLElement).isContentEditable,
+ );
+ expect(isEditable).toBe(true);
+ }).toPass({ timeout: Timeout.MEDIUM });
+
+ // Clear the existing text and type new text
+ await heading.press("Meta+A");
+ await heading.type("Hello from E2E Test");
+
+ // Click outside to finish editing
+ await frame.locator("body").click({ position: { x: 10, y: 10 } });
+
+ // Verify the changes are applied in the UI
+ await expect(frame.getByText("Hello from E2E Test")).toBeVisible({
+ timeout: Timeout.MEDIUM,
+ });
+
+ // Verify the visual changes dialog appears
+ await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
+ timeout: Timeout.MEDIUM,
+ });
+
+ // Save the changes
+ await po.page.getByRole("button", { name: "Save Changes" }).click();
+
+ // Wait for the success toast
+ await po.waitForToastWithText("Visual changes saved to source files");
+
+ // Verify that the changes are applied to the codebase
+ await po.snapshotAppFiles({
+ name: "visual-editing-text-content",
+ files: ["src/pages/Index.tsx"],
+ });
+});
+
+testSkipIfWindows("discard changes", async ({ po }) => {
+ await po.setUpDyadPro();
+ await po.sendPrompt("tc=basic");
+ await po.clickTogglePreviewPanel();
+ await po.clickPreviewPickElement();
+
+ // Select a component
+ await po
+ .getPreviewIframeElement()
+ .contentFrame()
+ .getByRole("heading", { name: "Welcome to Your Blank App" })
+ .click();
+
+ // Wait for the toolbar to appear (check for the Margin button which is always visible)
+ const marginButton = po.page.getByRole("button", { name: "Margin" });
+ await expect(marginButton).toBeVisible({
+ timeout: Timeout.MEDIUM,
+ });
+
+ // Ensure the toolbar has proper coordinates before clicking
+ await expect(async () => {
+ const box = await marginButton.boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.y).toBeGreaterThan(0);
+ }).toPass({ timeout: Timeout.MEDIUM });
+
+ // Click on margin button to open the margin popover
+ await marginButton.click();
+
+ // Wait for the popover to fully open by checking for the popover content container
+ const marginDialog = po.page
+ .locator('[role="dialog"]')
+ .filter({ hasText: "Margin" });
+ await expect(marginDialog).toBeVisible({
+ timeout: Timeout.LONG,
+ });
+
+ // Edit margin
+ const marginXInput = po.page.getByLabel("Horizontal");
+ await marginXInput.fill("30");
+
+ const marginYInput = po.page.getByLabel("Vertical");
+ await marginYInput.fill("30");
+
+ // Close the popover
+ await po.page.keyboard.press("Escape");
+
+ // Wait for the popover to close
+ await expect(marginDialog).not.toBeVisible({
+ timeout: Timeout.MEDIUM,
+ });
+
+ // Check if the changes are applied to UI
+ await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
+ timeout: Timeout.MEDIUM,
+ });
+
+ // Take a snapshot of the app files before discarding
+ const appPathBefore = await po.getCurrentAppPath();
+ const appFileBefore = fs.readFileSync(
+ path.join(appPathBefore, "src", "pages", "Index.tsx"),
+ "utf-8",
+ );
+
+ // Discard the changes
+ await po.page.getByRole("button", { name: "Discard" }).click();
+
+ // Verify the visual changes dialog is gone
+ await expect(po.page.getByText(/\d+ component[s]? modified/)).not.toBeVisible(
+ { timeout: Timeout.MEDIUM },
+ );
+
+ // Verify that the changes are NOT applied to codebase
+ const appFileAfter = fs.readFileSync(
+ path.join(appPathBefore, "src", "pages", "Index.tsx"),
+ "utf-8",
+ );
+
+ // The file content should be the same as before
+ expect(appFileAfter).toBe(appFileBefore);
+});
diff --git a/package-lock.json b/package-lock.json
index 9c4a8b6..1621c85 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@ai-sdk/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3",
"@ai-sdk/xai": "^2.0.16",
+ "@babel/parser": "^7.28.5",
"@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.1",
"@lexical/react": "^0.33.1",
@@ -81,6 +82,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"react-shiki": "^0.5.2",
+ "recast": "^0.23.11",
"remark-gfm": "^4.0.1",
"shell-env": "^4.0.1",
"shiki": "^3.2.1",
@@ -638,9 +640,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
- "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -669,12 +671,12 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
- "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.4"
+ "@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -755,13 +757,13 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
- "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1"
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -8002,6 +8004,18 @@
"node": ">=12"
}
},
+ "node_modules/ast-types": {
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
+ "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -11450,6 +11464,19 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/esquery": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
@@ -18462,6 +18489,22 @@
"node": ">= 6"
}
},
+ "node_modules/recast": {
+ "version": "0.23.11",
+ "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
+ "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==",
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.16.1",
+ "esprima": "~4.0.0",
+ "source-map": "~0.6.1",
+ "tiny-invariant": "^1.3.3",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/rechoir": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
@@ -19721,7 +19764,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
diff --git a/package.json b/package.json
index f2bca28..298dc48 100644
--- a/package.json
+++ b/package.json
@@ -94,6 +94,7 @@
"@ai-sdk/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3",
"@ai-sdk/xai": "^2.0.16",
+ "@babel/parser": "^7.28.5",
"@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.1",
"@lexical/react": "^0.33.1",
@@ -157,6 +158,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"react-shiki": "^0.5.2",
+ "recast": "^0.23.11",
"remark-gfm": "^4.0.1",
"shell-env": "^4.0.1",
"shiki": "^3.2.1",
diff --git a/src/__tests__/style-utils.test.ts b/src/__tests__/style-utils.test.ts
new file mode 100644
index 0000000..4b417b5
--- /dev/null
+++ b/src/__tests__/style-utils.test.ts
@@ -0,0 +1,118 @@
+import { describe, it, expect } from "vitest";
+import { stylesToTailwind } from "../utils/style-utils";
+
+describe("convertSpacingToTailwind", () => {
+ describe("margin conversion", () => {
+ it("should convert equal margins on all sides", () => {
+ const result = stylesToTailwind({
+ margin: { left: "16px", right: "16px", top: "16px", bottom: "16px" },
+ });
+ expect(result).toEqual(["m-[16px]"]);
+ });
+
+ it("should convert equal horizontal margins", () => {
+ const result = stylesToTailwind({
+ margin: { left: "16px", right: "16px" },
+ });
+ expect(result).toEqual(["mx-[16px]"]);
+ });
+
+ it("should convert equal vertical margins", () => {
+ const result = stylesToTailwind({
+ margin: { top: "16px", bottom: "16px" },
+ });
+ expect(result).toEqual(["my-[16px]"]);
+ });
+ });
+
+ describe("padding conversion", () => {
+ it("should convert equal padding on all sides", () => {
+ const result = stylesToTailwind({
+ padding: { left: "20px", right: "20px", top: "20px", bottom: "20px" },
+ });
+ expect(result).toEqual(["p-[20px]"]);
+ });
+
+ it("should convert equal horizontal padding", () => {
+ const result = stylesToTailwind({
+ padding: { left: "12px", right: "12px" },
+ });
+ expect(result).toEqual(["px-[12px]"]);
+ });
+
+ it("should convert equal vertical padding", () => {
+ const result = stylesToTailwind({
+ padding: { top: "8px", bottom: "8px" },
+ });
+ expect(result).toEqual(["py-[8px]"]);
+ });
+ });
+
+ describe("combined margin and padding", () => {
+ it("should handle both margin and padding", () => {
+ const result = stylesToTailwind({
+ margin: { left: "16px", right: "16px" },
+ padding: { top: "8px", bottom: "8px" },
+ });
+ expect(result).toContain("mx-[16px]");
+ expect(result).toContain("py-[8px]");
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ describe("edge cases: equal horizontal and vertical spacing", () => {
+ it("should consolidate px = py to p when values match", () => {
+ const result = stylesToTailwind({
+ padding: { left: "16px", right: "16px", top: "16px", bottom: "16px" },
+ });
+ // When all four sides are equal, should use p-[]
+ expect(result).toEqual(["p-[16px]"]);
+ });
+
+ it("should consolidate mx = my to m when values match (but not all four sides)", () => {
+ const result = stylesToTailwind({
+ margin: { left: "20px", right: "20px", top: "20px", bottom: "20px" },
+ });
+ // When all four sides are equal, should use m-[]
+ expect(result).toEqual(["m-[20px]"]);
+ });
+
+ it("should not consolidate when px != py", () => {
+ const result = stylesToTailwind({
+ padding: { left: "16px", right: "16px", top: "8px", bottom: "8px" },
+ });
+ expect(result).toContain("px-[16px]");
+ expect(result).toContain("py-[8px]");
+ expect(result).toHaveLength(2);
+ });
+
+ it("should not consolidate when mx != my", () => {
+ const result = stylesToTailwind({
+ margin: { left: "20px", right: "20px", top: "10px", bottom: "10px" },
+ });
+ expect(result).toContain("mx-[20px]");
+ expect(result).toContain("my-[10px]");
+ expect(result).toHaveLength(2);
+ });
+
+ it("should handle case where left != right", () => {
+ const result = stylesToTailwind({
+ padding: { left: "16px", right: "12px", top: "8px", bottom: "8px" },
+ });
+ expect(result).toContain("pl-[16px]");
+ expect(result).toContain("pr-[12px]");
+ expect(result).toContain("py-[8px]");
+ expect(result).toHaveLength(3);
+ });
+
+ it("should handle case where top != bottom", () => {
+ const result = stylesToTailwind({
+ margin: { left: "20px", right: "20px", top: "10px", bottom: "15px" },
+ });
+ expect(result).toContain("mx-[20px]");
+ expect(result).toContain("mt-[10px]");
+ expect(result).toContain("mb-[15px]");
+ expect(result).toHaveLength(3);
+ });
+ });
+});
diff --git a/src/atoms/previewAtoms.ts b/src/atoms/previewAtoms.ts
index acaa60c..92ff10e 100644
--- a/src/atoms/previewAtoms.ts
+++ b/src/atoms/previewAtoms.ts
@@ -1,6 +1,20 @@
-import { ComponentSelection } from "@/ipc/ipc_types";
+import { ComponentSelection, VisualEditingChange } from "@/ipc/ipc_types";
import { atom } from "jotai";
export const selectedComponentsPreviewAtom = atom([]);
+export const visualEditingSelectedComponentAtom =
+ atom(null);
+
+export const currentComponentCoordinatesAtom = atom<{
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+} | null>(null);
+
export const previewIframeRefAtom = atom(null);
+
+export const pendingVisualChangesAtom = atom