@@ -268,6 +268,7 @@ export const PREVIEW_CSP = [
268268
269269/** Tightest sandbox that still allows interactive demos. No same-origin, popups, forms, or modals. */
270270export const PREVIEW_SANDBOX = "allow-scripts" ;
271+ const PREVIEW_FALLBACK_BACKGROUND = "#080a08" ;
271272
272273/** Deny browser/device capabilities that interactive demos do not need. */
273274export const PREVIEW_ALLOW = [
@@ -297,12 +298,13 @@ function scrollBridge(id: string): string {
297298
298299// Stops ordinary links and submissions from replacing the preview with a
299300// phishing page. The sandbox remains the primary boundary for scripted escape.
300- const PREVIEW_NAVIGATION_GUARD = `<script>(function(){function blockedLink(target){var link=target&&target.closest&&target.closest("a[href] ");if(!link)return false;link.removeAttribute("ping");var href=(link.getAttribute("href")||"") .trim();return href!==""&&href.charAt(0)!=="#"}addEventListener("click",function(event){if(blockedLink(event.target))event.preventDefault()},true);addEventListener("auxclick",function(event){if(blockedLink(event.target))event.preventDefault()},true);addEventListener("submit",function(event){event.preventDefault()},true)})()</scr` + `ipt>` ;
301+ const PREVIEW_NAVIGATION_GUARD = `<script>(function(){var XLINK="http://www.w3.org/1999/xlink"; function blockedLink(target){var link=target&&target.closest&&target.closest("a,area ");if(!link)return false;link.removeAttribute("ping");var href=(link.getAttribute("href")||link.getAttributeNS(XLINK,"href")||"") .trim();if( href!==""&&href.charAt(0)!=="#")return true;return false }addEventListener("click",function(event){if(blockedLink(event.target))event.preventDefault()},true);addEventListener("auxclick",function(event){if(blockedLink(event.target))event.preventDefault()},true);addEventListener("submit",function(event){event.preventDefault()},true)})()</scr` + `ipt>` ;
301302
302303/** Strip navigation gadgets that can leave the srcdoc document before CSP helps. */
303304function stripPreviewNavigationGadgets ( code : string ) : string {
304305 return code
305306 . replace ( / < b a s e \b [ ^ > ] * > / gi, "" )
307+ . replace ( / < a r e a \b [ ^ > ] * > / gi, "" )
306308 . replace ( / < m e t a \b [ ^ > ] * h t t p - e q u i v \s * = \s * ( [ " ' ] ? ) r e f r e s h \1[ ^ > ] * > / gi, "" )
307309 . replace (
308310 / < l i n k \b (? = [ ^ > ] * \b r e l \s * = \s * (?: " [ ^ " ] * \b (?: p r e c o n n e c t | d n s - p r e f e t c h | p r e f e t c h | p r e r e n d e r | m o d u l e p r e l o a d ) \b [ ^ " ] * " | ' [ ^ ' ] * \b (?: p r e c o n n e c t | d n s - p r e f e t c h | p r e f e t c h | p r e r e n d e r | m o d u l e p r e l o a d ) \b [ ^ ' ] * ' | [ ^ \s > ] * (?: p r e c o n n e c t | d n s - p r e f e t c h | p r e f e t c h | p r e r e n d e r | m o d u l e p r e l o a d ) [ ^ \s > ] * ) ) [ ^ > ] * > / gi,
@@ -320,7 +322,8 @@ export function hardenPreviewDocument(
320322) : string {
321323 const untrusted = stripPreviewNavigationGadgets ( code ) ;
322324 const cspMeta = `<meta http-equiv="Content-Security-Policy" content="${ PREVIEW_CSP } ">` ;
323- const securityBootstrap = `${ cspMeta } ${ PREVIEW_NAVIGATION_GUARD } ` ;
325+ const fallbackStyle = `<style>html,body{min-height:100%;background:${ PREVIEW_FALLBACK_BACKGROUND } }</style>` ;
326+ const securityBootstrap = `${ cspMeta } ${ fallbackStyle } ${ PREVIEW_NAVIGATION_GUARD } ` ;
324327 const withoutDoctype = untrusted . replace ( / < ! d o c t y p e \b [ ^ > ] * > / gi, "" ) ;
325328 const htmlAttrs = withoutDoctype . match ( / < h t m l \b ( [ ^ > ] * ) > / i) ?. [ 1 ] || "" ;
326329 const completeDocument = withoutDoctype . match (
@@ -347,7 +350,7 @@ export function hardenPreviewDocument(
347350 */
348351export function openHardenedPreview ( code : string , title = "AI Battle preview" ) : void {
349352 const inner = hardenPreviewDocument ( code ) ;
350- const wrapper = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${ esc ( title ) } </title><style>html,body{margin:0;height:100%;background:#0a0a0a} iframe{display:block;width:100%;height:100%;border:0;background:#fff} </style></head><body><iframe sandbox="${ PREVIEW_SANDBOX } " allow="${ PREVIEW_ALLOW } " referrerpolicy="no-referrer" srcdoc="${ esc ( inner ) } " title="${ esc ( title ) } "></iframe></body></html>` ;
353+ const wrapper = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${ esc ( title ) } </title><style>html,body{margin:0;height:100%;background:${ PREVIEW_FALLBACK_BACKGROUND } } iframe{display:block;width:100%;height:100%;border:0;background:${ PREVIEW_FALLBACK_BACKGROUND } } </style></head><body><iframe sandbox="${ PREVIEW_SANDBOX } " csp=" ${ esc ( PREVIEW_CSP ) } " allow="${ PREVIEW_ALLOW } " referrerpolicy="no-referrer" srcdoc="${ esc ( inner ) } " title="${ esc ( title ) } "></iframe></body></html>` ;
351354 const url = URL . createObjectURL ( new Blob ( [ wrapper ] , { type : "text/html" } ) ) ;
352355 window . open ( url , "_blank" , "noopener,noreferrer" ) ;
353356 setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 60_000 ) ;
@@ -356,14 +359,37 @@ export function openHardenedPreview(code: string, title = "AI Battle preview"):
356359function previewIframeHTML ( r : ResultView , resultKey : string ) : string {
357360 const doc = hardenPreviewDocument ( r . code , { bridgeId : resultKey } ) ;
358361 return `
359- <div class="relative h-full bg-[var(--color-panel )] p-1.5">
362+ <div class="relative h-full bg-[var(--color-surface )] p-1.5">
360363 <button data-action="reload-preview" data-model="${ esc ( resultKey ) } " aria-label="Restart preview" class="absolute right-2 top-2 z-10 flex items-center gap-1 rounded-md border border-[var(--color-line)] bg-[var(--color-panel)]/90 px-2 py-1 text-[10px] text-[var(--color-ink-dim)] backdrop-blur transition-colors hover:text-[var(--color-ink)]" title="restart the demo">
361364 ${ svg ( "i-refresh" , "size-3.5" ) } <span>restart</span>
362365 </button>
363- <iframe data-preview="${ esc ( resultKey ) } " class="h-full w-full rounded-lg bg-white shadow-inner" sandbox="${ PREVIEW_SANDBOX } " allow="${ PREVIEW_ALLOW } " referrerpolicy="no-referrer" srcdoc="${ esc ( doc ) } " title="Preview generated by ${ esc ( r . id ) } "></iframe>
366+ <iframe data-preview="${ esc ( resultKey ) } " class="h-full w-full rounded-lg bg-[var(--color-surface)] opacity-0 shadow-inner transition-opacity duration-300 ease-out motion-reduce:transition-none " sandbox="${ PREVIEW_SANDBOX } " csp=" ${ esc ( PREVIEW_CSP ) } " allow="${ PREVIEW_ALLOW } " referrerpolicy="no-referrer" srcdoc="${ esc ( doc ) } " title="Preview generated by ${ esc ( r . id ) } "></iframe>
364367 </div>` ;
365368}
366369
370+ /** Fade previews in only after their srcdoc has finished loading. */
371+ export function installPreviewFade ( root : Document = document ) : void {
372+ root . addEventListener (
373+ "load" ,
374+ ( event ) => {
375+ const frame = event . target ;
376+ if ( ! ( frame instanceof HTMLIFrameElement ) || ! frame . matches ( "iframe[data-preview]" ) )
377+ return ;
378+ requestAnimationFrame ( ( ) => {
379+ frame . classList . remove ( "opacity-0" ) ;
380+ frame . classList . add ( "opacity-100" ) ;
381+ } ) ;
382+ } ,
383+ true ,
384+ ) ;
385+ }
386+
387+ export function restartPreview ( frame : HTMLIFrameElement ) : void {
388+ frame . classList . remove ( "opacity-100" ) ;
389+ frame . classList . add ( "opacity-0" ) ;
390+ frame . srcdoc = frame . srcdoc ;
391+ }
392+
367393// Content for a finished result (output / code / preview).
368394export function doneContentHTML (
369395 r : ResultView ,
0 commit comments