diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index 75717a03..cddb31e2 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -40,7 +40,8 @@
"media-src": "'self' blob: data:",
"style-src": "'self' 'unsafe-inline'",
"font-src": "'self' data:",
- "script-src": "'self' 'wasm-unsafe-eval'"
+ "script-src": "'self' 'wasm-unsafe-eval'",
+ "frame-src": "http://127.0.0.1:* http://localhost:*"
},
"dangerousDisableAssetCspModification": [
"style-src"
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index c9f90780..1091fc0b 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -25,6 +25,8 @@ const AgentsPage = lazy(() => import("@/pages/agents/agents-page"));
const AgentNewPage = lazy(() => import("@/pages/agents/agent-new"));
const AgentDetailPage = lazy(() => import("@/pages/agents/agent-detail"));
const FilesPage = lazy(() => import("@/pages/files/files-page"));
+const ArtifactsPage = lazy(() => import("@/pages/artifacts/artifacts-page"));
+const ArtifactPage = lazy(() => import("@/pages/artifacts/artifact-page"));
const AutomationsPage = lazy(() => import("@/pages/automations/automations-page"));
const PluginsPage = lazy(() => import("@/pages/plugins/plugins-page"));
const ActivityPage = lazy(() => import("@/pages/activity/activity-page"));
@@ -203,6 +205,8 @@ export function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/apps/desktop/src/components/artifacts/artifact-frame.tsx b/apps/desktop/src/components/artifacts/artifact-frame.tsx
new file mode 100644
index 00000000..5710b328
--- /dev/null
+++ b/apps/desktop/src/components/artifacts/artifact-frame.tsx
@@ -0,0 +1,273 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
+import { MonitorSmartphone } from "lucide-react";
+import { cloudContext, coreUrl } from "@/lib/core";
+import { openExternal } from "@/lib/desktop";
+import { useTheme } from "@/components/theme-provider";
+import { cn } from "@/lib/utils";
+
+/**
+ * Runs an artifact's HTML in the core's sandbox page (`/api/artifacts/frame`): an opaque origin with its own policy, so
+ * the code can load CDN libraries but never reaches the app, its token or Godmode's API. The document gets
+ * `window.godmode` (ARTIFACT_BRIDGE_DOC in @godmode/shared) and talks to the app only through postMessage.
+ */
+
+const LIGHT = {
+ bg: "#faf9f5",
+ card: "#ffffff",
+ fg: "#1a1a1a",
+ muted: "#75716a",
+ border: "rgb(38 32 22 / 0.1)",
+ accent: "#0eca7b",
+ "accent-fg": "#04130c",
+ positive: "#087f4d",
+ negative: "#d4101f",
+};
+
+const DARK = {
+ bg: "#1c1b19",
+ card: "#232220",
+ fg: "#f4f2ed",
+ muted: "#a29e96",
+ border: "rgb(255 250 240 / 0.09)",
+ accent: "#2fd690",
+ "accent-fg": "#06140d",
+ positive: "#2fd690",
+ negative: "#f0525b",
+};
+
+const FONT = `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
+
+const vars = (p: Record) => Object.entries(p).map(([k, v]) => `--gm-${k}:${v};`).join("") + `--gm-font:${FONT};`;
+
+const BASE_STYLE = `:root{${vars(LIGHT)}color-scheme:light}:root.dark{${vars(DARK)}color-scheme:dark}html,body{margin:0}body{background:var(--gm-bg);color:var(--gm-fg);font-family:var(--gm-font);-webkit-font-smoothing:antialiased}`;
+
+/** Runs first in the artifact's document. Kept as plain ES5 so any page can carry it. */
+function bridgeScript(token: string, data: unknown, theme: "light" | "dark", standalone = false): string {
+ const json = (v: unknown) => JSON.stringify(v ?? null).replace(/ `\\u${c.charCodeAt(0).toString(16)}`);
+ return ``;
+}
+
+/** The artifact's HTML with the bridge first in (a fragment gets a document around it). */
+export function artifactDocument(html: string, bridge: string): string {
+ // Tags inside comments and scripts don't count: blank them out (same length) before looking.
+ const plain = html.replace(/|