diff --git a/bin/check.sh b/bin/check.sh
index 02b9b21..48be6a2 100644
--- a/bin/check.sh
+++ b/bin/check.sh
@@ -25,6 +25,16 @@ else
echo "-- skipped: interface/web/node_modules missing (npm install)"
fi
+echo "== frontend unit tests =="
+# The JSX/TSX transform behind the preview window is a pure module with a
+# node --test suite. Nothing else in the frontend has tests, so this is cheap;
+# without it the transform's silent-wrong cases go unguarded.
+if [ -d interface/web/node_modules ]; then
+ (cd interface/web && npm test) || fail=1
+else
+ echo "-- skipped: interface/web/node_modules missing (npm install)"
+fi
+
echo "== powershell parse =="
# The Windows installer has died at parse twice. Cheap to catch here if pwsh
# happens to be installed on the Linux box; the ASCII guard in tests/ is the
diff --git a/interface/web/index.html b/interface/web/index.html
index 6c697db..6b32eed 100644
--- a/interface/web/index.html
+++ b/interface/web/index.html
@@ -2,6 +2,9 @@
);
}
@@ -117,6 +126,435 @@ function CodeBlock({ lang, value, streaming }) {
);
}
+// Content-Security-Policy for the rendered preview. Together with the iframe's
+// `sandbox` attribute below, this is the entire trust boundary for model-
+// authored HTML/SVG, so it stays conservative rather than convenient:
+// - script-src/style-src 'unsafe-inline' inline ` below is safe unescaped because this module is emitted as an
+// external .js asset - it is never inlined into index.html, where the HTML
+// parser would end the surrounding script tag early.
+//
+// postMessage is the one channel an opaque-origin sandboxed frame still has to
+// the parent, and this is the entire protocol over it: one message shape,
+// outbound only, carrying a content height and an error string. Nothing flows
+// the other way. The parent treats both fields as untrusted data - the height
+// is clamped and the message is rendered as text, never as markup - because
+// they were produced by the same code the sandbox exists to contain.
+//
+// Without this the frame is silent: a preview whose script throws just renders
+// blank, which is why the server-side validator in synapse/tools.py has to
+// guess at runtime failures it can't observe.
+const _PREVIEW_BOOTSTRAP = ``;
+
+// Substituted with the real line offset once the document is assembled and its
+// shell can be measured. Sits on one line so replacing it can't shift any.
+const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__";
+
+/**
+ * Build the sandboxed document for a fence. Returns {doc, error}: a language
+ * whose source doesn't parse (JSX, today) has no document to show, and the
+ * caller renders the message instead of a frame.
+ *
+ * The shell - charset, CSP, bootstrap - is identical for every language; only
+ * the body differs, so only that part goes through the registry. Nothing about
+ * the sandboxing is per-language and shouldn't be: SVG can carry ` +
+ _PREVIEW_BOOTSTRAP +
+ "";
+
+ // Lines of shell above the user's own code: the document head, plus whatever
+ // the language puts in the body ahead of it (the Preact build, for JSX).
+ const offset = (head.match(/\n/g) || []).length + body.userOffset;
+
+ return {
+ doc: (head + body.html + "").replace(_OFFSET_TOKEN, String(offset)),
+ error: "",
+ };
+}
+
+// Auto-height bounds. The frame is sized from content, and content sized in
+// viewport/percentage units is therefore sized from the frame - a body with its
+// own margin makes that loop grow by the margin on every pass. Measuring the
+// body box rather than documentElement is what actually settles that loop;
+// _MAX_PREVIEW_H then caps anything still climbing within a few iterations.
+//
+// _MAX_H_STEPS is only a last resort against a document that oscillates
+// forever, so it is generous: an interactive component legitimately changes
+// height on every click, and a tight budget would freeze the frame mid-session
+// at whatever size it happened to reach.
+const _MIN_PREVIEW_H = 160;
+const _MAX_PREVIEW_H = 720;
+const _MAX_H_STEPS = 60;
+
+// A frame that never posts again — a synchronous `while(true)` in the user's
+// own script, or a runaway re-render loop the bootstrap's own coalescing
+// can't outpace — has nothing else to signal it. Silence past this long since
+// mount (or since the last message) is treated as hung and the frame is torn
+// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never
+// gets close to this.
+const _WATCHDOG_MS = 6000;
+
+// Live preview for a renderable fenced block: a Preview/Code toggle rendered
+// via a sandboxed iframe whose document is an encoded data: URL.
+//
+// Trust boundary: `sandbox="allow-scripts"` — deliberately without
+// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No
+// allow-same-origin forces the iframe onto an opaque origin, which is what
+// actually matters here: even the inline scripts the CSP allows to run can't
+// read this app's cookies/localStorage, can't call its API (no credentialed
+// or same-origin fetch is possible), and can't reach `window.parent`. The CSP
+// above blocks resource and script-initiated network access. The embedding
+// document's `frame-src data:` policy in index.html closes a separate CSP gap:
+// a child is otherwise allowed to navigate its own browsing context to a URL.
+// The initial data: document is allowed and inherits the parent policy, while
+// an http(s) navigation is rejected before its request is sent. Nothing here
+// substitutes for a general code-execution sandbox (Docker, WASM, etc.);
+// model-authored code runs only inside the browser's sandboxed frame.
+function RenderBlock({ lang, value, streaming }) {
+ const [tab, setTab] = useState("preview");
+ const [expanded, setExpanded] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const copy = () => {
+ navigator.clipboard.writeText(value.trimEnd()).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ });
+ };
+
+ // Don't preview a block whose fence hasn't closed yet - it's incomplete
+ // markup by definition, and re-pointing an iframe at a half-formed
+ // document on every streamed token is both wasteful and flickery. Code view
+ // already has its own streaming indicator (the same dot CodeBlock uses).
+ const showPreview = tab === "preview" && !streaming;
+
+ return (
+
+ {showPreview ? (
+ // Keyed by the markup: new markup is a new document, so remounting is
+ // what resets the reported error and measured height. No reset effect.
+
+ ) : (
+
+ {value.trimEnd()}
+
+ )}
+
+ );
+}
+
+// The sandboxed frame plus the two things it reports back: its content height
+// and its first uncaught error. Split out of RenderBlock so the caller can key
+// it by markup - a fresh document then gets fresh state by remounting.
+function PreviewFrame({ lang, value, expanded }) {
+ const [error, setError] = useState("");
+ const [doc, setDoc] = useState("");
+ const [buildError, setBuildError] = useState("");
+ const [height, setHeight] = useState(240);
+ const [hung, setHung] = useState(false);
+ const frameRef = useRef(null);
+ const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
+ const stepsRef = useRef(0);
+ const lastMsgRef = useRef(0); // set for real by the watchdog effect below
+
+ // Receive the bootstrap's reports. The frame is on an opaque origin, so
+ // e.origin is the string "null" and proves nothing - identify the sender by
+ // its window instead, which content inside the sandbox cannot forge.
+ useEffect(() => {
+ const onMessage = (e) => {
+ if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
+ const data = e.data;
+ if (!data || data.__nexusPreview !== 1) return;
+ lastMsgRef.current = Date.now();
+
+ if (typeof data.err === "string" && data.err) setError(data.err);
+
+ if (typeof data.h === "number" && Number.isFinite(data.h) && stepsRef.current < _MAX_H_STEPS) {
+ const next = Math.min(_MAX_PREVIEW_H, Math.max(_MIN_PREVIEW_H, Math.round(data.h)));
+ if (Math.abs(next - heightRef.current) >= 8) {
+ heightRef.current = next;
+ stepsRef.current += 1;
+ setHeight(next);
+ }
+ }
+ };
+ window.addEventListener("message", onMessage);
+ return () => window.removeEventListener("message", onMessage);
+ }, []);
+
+ // Watchdog: a frame that goes silent past _WATCHDOG_MS — most likely a
+ // synchronous infinite loop in the model's own script, which blocks even
+ // the bootstrap's heartbeat from ever running — gets torn down rather than
+ // left spinning. Checked on an interval rather than a single timeout so a
+ // message arriving late (slow compile, heavy first paint) keeps resetting
+ // the clock instead of tripping early.
+ useEffect(() => {
+ lastMsgRef.current = Date.now();
+ const id = setInterval(() => {
+ if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) {
+ setHung(true);
+ clearInterval(id);
+ }
+ }, 1000);
+ return () => clearInterval(id);
+ }, [lang, value]);
+
+ useEffect(() => {
+ let current = true;
+ setDoc("");
+ setBuildError("");
+ buildSrcDoc(lang, value).then((result) => {
+ if (!current) return;
+ setDoc(result.doc || "");
+ setBuildError(result.error || "");
+ });
+ return () => { current = false; };
+ }, [lang, value]);
+
+ // A build failure (JSX that doesn't parse) has no document to show at all, so
+ // the message stands in for the frame rather than sitting under it. A hung
+ // frame tears down the same way: dropping frameUrl unmounts the iframe,
+ // which is what actually stops a runaway script from holding the tab.
+ const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
+ const shown = hung
+ ? "Preview stopped responding (likely an infinite loop) and was stopped."
+ : buildError || error;
+
+ return (
+ <>
+ {frameUrl && (
+
+ )}
+ {shown && (
+
+ {shown}
+
+ )}
+ >
+ );
+}
+
+function _chromeButtonStyle(color) {
+ return {
+ background: "transparent",
+ border: "none",
+ color,
+ cursor: "pointer",
+ fontSize: "0.75rem",
+ padding: "0.1rem 0.3rem",
+ };
+}
+
+function TabButton({ active, disabled, onClick, children }) {
+ return (
+
+ );
+}
+
function TextBlock({ text }) {
const lines = text.split("\n");
const elements = [];
diff --git a/interface/web/src/Playbook.jsx b/interface/web/src/Playbook.jsx
index 50e9bd8..533ed2f 100644
--- a/interface/web/src/Playbook.jsx
+++ b/interface/web/src/Playbook.jsx
@@ -359,7 +359,7 @@ export function Playbook() {
/>
setForm(prev => ({ ...prev, tools: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
diff --git a/interface/web/src/preview/jsx-transform.js b/interface/web/src/preview/jsx-transform.js
new file mode 100644
index 0000000..d945175
--- /dev/null
+++ b/interface/web/src/preview/jsx-transform.js
@@ -0,0 +1,58 @@
+/*
+ * JSX/TSX compiler adapter.
+ *
+ * JSX and TypeScript are parsed by Sucrase rather than by preview-specific
+ * lexer code. The dependency is dynamically imported so ordinary chat and
+ * HTML/SVG previews do not download the compiler chunk. Only this small adapter
+ * stays in the main bundle.
+ *
+ * Sucrase's CommonJS transform is intentional: a preview frame has no module
+ * loader or network access, but languages.js can provide local React/Preact
+ * modules through a tiny `require` shim. Unsupported imports then fail loudly
+ * at evaluation time with the package name that cannot be loaded.
+ */
+
+export class TransformError extends Error {
+ constructor(message, options) {
+ super(message, options);
+ this.name = "TransformError";
+ }
+}
+
+/**
+ * Find fallback component declarations for model output that omits an export.
+ *
+ * This is deliberately not syntax transformation. Sucrase owns all parsing;
+ * these names only form guarded `typeof Name !== "undefined"` mount choices.
+ * A false match is therefore ignored at runtime. Default exports and App take
+ * precedence, so this compatibility fallback is used only for a bare component
+ * such as `function Counter() { ... }`.
+ */
+function componentCandidates(source) {
+ const names = [];
+ const declarations = /\b(?:function|class|const|let|var)\s+([A-Z][$\w]*)/g;
+ for (const match of source.matchAll(declarations)) {
+ if (!names.includes(match[1])) names.push(match[1]);
+ }
+ return names;
+}
+
+/** Compile a self-contained JSX/TSX component into browser-ready CommonJS. */
+export async function transform(source) {
+ const input = String(source ?? "");
+
+ try {
+ const { transform: compile } = await import("sucrase");
+ const { code } = compile(input, {
+ transforms: ["typescript", "jsx", "imports"],
+ jsxPragma: "h",
+ jsxFragmentPragma: "Fragment",
+ production: true,
+ filePath: "preview.tsx",
+ });
+ return { code, components: componentCandidates(input) };
+ } catch (error) {
+ const detail = error && error.message ? error.message : String(error);
+ throw new TransformError(`Could not compile JSX/TSX: ${detail}`, { cause: error });
+ }
+}
diff --git a/interface/web/src/preview/jsx-transform.test.js b/interface/web/src/preview/jsx-transform.test.js
new file mode 100644
index 0000000..39aa67b
--- /dev/null
+++ b/interface/web/src/preview/jsx-transform.test.js
@@ -0,0 +1,147 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { transform, TransformError } from "./jsx-transform.js";
+
+async function compile(source) {
+ return transform(source);
+}
+
+function assertRunnable(code) {
+ assert.doesNotThrow(() => new Function(
+ "module", "exports", "require", "h", "Fragment", code,
+ ));
+}
+
+test("compiles elements, attributes, spreads, children, and fragments", async () => {
+ const { code } = await compile(`
+ const view = <>
+
+
+
+ >;
+ `);
+ assertRunnable(code);
+ assert.match(code, /h\(Fragment/);
+ assert.match(code, /h\('section'/);
+ assert.doesNotMatch(code, / {
+ const { code } = await compile(
+ "const view =