@@ -127,6 +127,22 @@ const TAILWIND_V3 = `<script src="https://cdn.tailwindcss.com"></script>
127127 } } };
128128</script>` ;
129129
130+ // Decodes literal \uXXXX / \u{...} escape sequences in a string. Exported for
131+ // tests; its source is interpolated into the sandbox bootstrap below so the
132+ // iframe runs this exact implementation.
133+ export function decodeJsxUnicodeEscapes ( value : string ) : string {
134+ return value . replace (
135+ / \\ u \{ ( [ 0 - 9 a - f A - F ] { 1 , 6 } ) \} | \\ u ( [ 0 - 9 a - f A - F ] { 4 } ) / g,
136+ ( match , braced , plain ) => {
137+ try {
138+ return String . fromCodePoint ( Number . parseInt ( braced || plain , 16 ) ) ;
139+ } catch {
140+ return match ;
141+ }
142+ } ,
143+ ) ;
144+ }
145+
130146export function buildSandboxDocument (
131147 mode : SandboxMode ,
132148 // The PostHog host, when in-iframe analytics/replay is enabled. Opens CSP for
@@ -269,6 +285,33 @@ export function buildSandboxDocument(
269285 ),
270286 );
271287
288+ // JSX text and attribute strings never process \\uXXXX escapes (they render
289+ // verbatim, e.g. "\\u00b7" instead of "·"), but generated canvases still
290+ // contain them despite the prompt rules — decode at transpile time so both
291+ // new and already-saved canvases render the real characters. Escapes inside
292+ // JS string/template literals are untouched (Babel already decoded those).
293+ const decodeUnicodeEscapes = ${ decodeJsxUnicodeEscapes . toString ( ) } ;
294+ const jsxUnicodeEscapesPlugin = () => ({
295+ visitor: {
296+ JSXText(path) {
297+ const decoded = decodeUnicodeEscapes(path.node.value);
298+ if (decoded !== path.node.value) {
299+ path.node.value = decoded;
300+ }
301+ },
302+ JSXAttribute(path) {
303+ const v = path.node.value;
304+ if (v && v.type === "StringLiteral") {
305+ const decoded = decodeUnicodeEscapes(v.value);
306+ if (decoded !== v.value) {
307+ v.value = decoded;
308+ v.extra = undefined; // drop stale raw so the decoded value is emitted
309+ }
310+ }
311+ },
312+ },
313+ });
314+
272315 let root = null;
273316 // mount() is async and is called once per streamed code snapshot, so several
274317 // runs overlap on their awaits. Without ordering, a slower EARLIER (partial,
@@ -282,6 +325,7 @@ export function buildSandboxDocument(
282325 try {
283326 const out = Babel.transform(code, {
284327 filename: "canvas.tsx",
328+ plugins: [jsxUnicodeEscapesPlugin],
285329 presets: [
286330 ["react", { runtime: "automatic" }],
287331 ["typescript", { isTSX: true, allExtensions: true, onlyRemoveTypeImports: true }],
0 commit comments