import { encodeBase64, escapeAttribute, escapeHtml, escapeScript, escapeStyle, } from './artifact-escape.js';
import { applyAssetResolution, artifactCsp } from './artifact-asset-resolution.js';
import { encodeAssetFilename, findArtifactAssetRefs } from './artifact-asset-urls.js';
import { FRAME_SELECTION_SELECTOR, FRAME_STEPPER_FALLBACK_SELECTOR, frameStepperSelector, frameStepperVisibilityCss, } from './artifact-frame-selectors.js';
/** The neutral gray the hosted app presents artifacts on. The web stage rule
 *  (.artifact-stage[data-view-mode='fullscreen'] in ArtifactEntityView.svelte)
 *  must use the same value — a source-sync test pins it. */
export const ARTIFACT_PRESENT_BACKDROP = '#4a4a4a';
/** Shared dead-band for content-size churn, in px. Single-sourced HERE (core)
 *  so the iframe runtime — which interpolates it into its injected reporter
 *  script below — and the web host's per-slot recorder (which re-exports this
 *  through artifact-view-state.ts) can never drift apart: a tuning change one
 *  side silently filtered out would ship as a no-op. */
export const CONTENT_SIZE_DEAD_BAND_PX = 8;
function stripNetworkAssetTags(html) {
    return html
        .replace(/<link\b(?=[^>]*\brel\s*=\s*(?:"[^"]*\bstylesheet\b[^"]*"|'[^']*\bstylesheet\b[^']*'|[^\s>]*stylesheet[^\s>]*))[^>]*>/gi, '')
        .replace(/<script\b(?=[^>]*\bsrc\s*=)[^>]*>[\s\S]*?<\/script\s*>/gi, '');
}
function scriptTypeFromAttributes(attrs) {
    const match = attrs.match(/\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
    return (match?.[1] ?? match?.[2] ?? match?.[3] ?? null)?.trim().toLowerCase() ?? null;
}
function executableScriptKind(attrs) {
    const type = scriptTypeFromAttributes(attrs);
    if (!type || type === 'text/javascript' || type === 'application/javascript' || type === 'text/ecmascript' || type === 'application/ecmascript') {
        return 'classic';
    }
    return type === 'module' ? 'module' : null;
}
function scriptKindForPath(path) {
    return path.toLowerCase().endsWith('.mjs') ? 'module' : 'classic';
}
function deferInlineScripts(html) {
    const scripts = [];
    const nextHtml = html.replace(/<script\b((?:(?!\bsrc\s*=)[^>])*)>([\s\S]*?)<\/script\s*>/gi, (match, attrs, js) => {
        const kind = executableScriptKind(attrs);
        if (!kind)
            return match;
        if (js.trim())
            scripts.push({ path: `inline-${scripts.length + 1}.${kind === 'module' ? 'mjs' : 'js'}`, js, kind });
        return '';
    });
    return { html: nextHtml, scripts };
}
function styleTags(bundle) {
    return Object.entries(bundle.files)
        .filter(([path]) => path.toLowerCase().endsWith('.css'))
        .map(([path, css]) => `<style data-artifact-file="${escapeAttribute(path)}">\n${escapeStyle(css)}\n</style>`)
        .join('\n');
}
function requestedLibraries(bundle) {
    return bundle.libraries ?? [];
}
function libraryTags(bundle, sources = {}) {
    const libraries = requestedLibraries(bundle);
    if (libraries.length === 0)
        return '';
    const tags = [];
    for (const library of libraries) {
        if (library === 'd3' || library === 'motion') {
            const source = sources[library];
            if (!source)
                continue;
            tags.push(`<script data-artifact-library="${library}">\n${escapeScript(source)}\n</script>`);
        }
    }
    const asyncInitializers = [];
    if (libraries.includes('three') && sources.three) {
        asyncInitializers.push(`
      const decodeLibrarySource = (base64) => {
        const binary = atob(base64);
        const bytes = new Uint8Array(binary.length);
        for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
        return new TextDecoder().decode(bytes);
      };
      const threeSource = decodeLibrarySource(${JSON.stringify(encodeBase64(sources.three))});
      const threeCoreSource = ${sources.threeCore ? `decodeLibrarySource(${JSON.stringify(encodeBase64(sources.threeCore))})` : "''"};
      const threeCoreUrl = threeCoreSource
        ? URL.createObjectURL(new Blob([threeCoreSource], { type: 'text/javascript' }))
        : null;
      const threeModuleSource = threeCoreUrl
        ? threeSource.replace(/from\\s*(['"])\\.\\/three\\.core\\.min\\.js\\1/g, 'from ' + JSON.stringify(threeCoreUrl))
        : threeSource;
      const threeUrl = URL.createObjectURL(new Blob([threeModuleSource], { type: 'text/javascript' }));
      try {
        window.THREE = await import(threeUrl);
      } finally {
        URL.revokeObjectURL(threeUrl);
        if (threeCoreUrl) URL.revokeObjectURL(threeCoreUrl);
      }
    `);
    }
    tags.push(`<script data-artifact-libraries-ready>
window.__STUDIOGRAPH_ARTIFACT_LIBRARIES__ = ${JSON.stringify(libraries)};
window.__STUDIOGRAPH_ARTIFACT_LIBRARIES_READY__ = (async () => {
${asyncInitializers.join('\n')}
  return true;
})().catch((error) => {
  console.error('Artifact library load failed', error);
  throw error;
});
</script>`);
    return tags.join('\n');
}
function scriptTags(bundle, inlineScripts = []) {
    const jsEntries = Object.entries(bundle.files)
        .filter(([path]) => /\.(m?js)$/i.test(path))
        .map(([path, js]) => ({ path, js, kind: scriptKindForPath(path) }));
    if (requestedLibraries(bundle).length > 0) {
        const payload = JSON.stringify([...inlineScripts, ...jsEntries]);
        return `<script data-artifact-user-scripts>
(async () => {
  await (window.__STUDIOGRAPH_ARTIFACT_LIBRARIES_READY__ || Promise.resolve());
  const scripts = ${escapeScript(payload)};
  const scriptUrls = [];
  for (const script of scripts) {
    await new Promise((resolve, reject) => {
      const url = URL.createObjectURL(new Blob([
        String(script.js) + '\\n//# sourceURL=studiograph-artifact://' + String(script.path)
      ], { type: 'text/javascript' }));
      scriptUrls.push(url);
      if (script.kind === 'module') {
        import(url).then(resolve, reject);
        return;
      }
      const element = document.createElement('script');
      element.src = url;
      element.async = false;
      element.dataset.artifactFile = String(script.path);
      element.onload = () => resolve();
      element.onerror = () => reject(new Error('Could not load artifact script: ' + String(script.path)));
      document.head.appendChild(element);
    });
  }
  if (document.readyState !== 'loading') {
    try { document.dispatchEvent(new Event('DOMContentLoaded', { bubbles: true, cancelable: true })); } catch {}
  }
  if (document.readyState === 'complete') {
    try { window.dispatchEvent(new Event('load')); } catch {}
  }
  setTimeout(() => {
    for (const url of scriptUrls) URL.revokeObjectURL(url);
  }, 0);
})().catch((error) => {
  console.error('Artifact script error', error);
  throw error;
});
</script>`;
    }
    return jsEntries
        .map(({ path, js, kind }) => `<script${kind === 'module' ? ' type="module"' : ''} data-artifact-file="${escapeAttribute(path)}">\n${escapeScript(js)}\n</script>`)
        .join('\n');
}
function getStepperControl(bundle) {
    return bundle.display?.controls?.stepper ?? null;
}
const FONT_REF_RE = /\.(?:woff2?|ttf|otf)$/i;
const FONT_PRELOAD_MAX = 12;
/** `<link rel="preload" as="font">` tags for every workspace font the bundle
 *  references (iframe host only — the in-app/share document). Without these the
 *  browser only starts fetching a font after stylesheets apply and text lays
 *  out — on a cold load that fetch loses the race against the FOUT guard's
 *  settle ceiling and the swap still flashes. Emitted with the canonical
 *  `/api/assets/...` form; the finalize pass rewrites the hrefs through the
 *  SAME AssetResolution as every other asset URL (token `?at=` / share path),
 *  so preloads can never disagree with the CSP. `crossorigin` is required for
 *  font preloads to match the CSS font fetch mode (the sandbox's opaque origin
 *  makes all loads CORS). */
function fontPreloadTags(bundle, resolution) {
    if (!resolution || resolution.mode === 'none')
        return '';
    // Token mode with the mint still in flight: every preload would go out
    // un-tokenized and 401 (surfacing as opaque-origin CORS noise in the
    // console). The host defers building until the mint settles, but if a
    // tokenless build happens anyway (mint failed → proceed tokenless), the
    // CSS @font-face fetches degrade gracefully — guaranteed-dead preloads
    // must simply never be emitted.
    if (resolution.mode === 'token' && !resolution.token)
        return '';
    // Relative (export) mode resolves fonts to bundled ./assets paths — no
    // preload tag needed (and a preload of an unbundled placeholder would 404).
    if (resolution.mode === 'relative')
        return '';
    const text = Object.values(bundle.files).filter((v) => typeof v === 'string').join('\n');
    return findArtifactAssetRefs(text)
        .filter((ref) => FONT_REF_RE.test(ref.filename))
        .slice(0, FONT_PRELOAD_MAX)
        .map((ref) => `<link rel="preload" as="font" crossorigin href="/api/assets/${ref.assetId}/${encodeAssetFilename(ref.filename)}">`)
        .join('');
}
// ---------------------------------------------------------------------------
// Unified artifact runtime (eng review D2). ONE script, both host modes.
//
// The frame stepper — frame detection, active-frame attribution, keyboard nav,
// click-to-step — is the part that historically drifted between the in-app and
// the standalone-export runtimes (the `.slide` selector bug). It now lives in
// these three SHARED fragments, emitted verbatim into BOTH host bodies, so the
// two can never disagree about which elements are frames. Each host body
// supplies the small hooks the shared fragments call:
//   - `send(payload)`        postMessage to the host (iframe) / no-op (none)
//   - `sendFrameState()`     report the active frame to the host / no-op
//   - `sendActivity()`       report user activity to the host / no-op
//   - `applyFrameFit()`      self-fit the active frame to the viewport (none) /
//                            no-op (iframe — the host owns scaling)
//   - `stepperEnabled`       gate (runtime expr in iframe, literal true in none)
//   - `interactiveSelector`  elements a click must NOT hijack for stepping
// ---------------------------------------------------------------------------
/** Stepper detection + active-frame state. Depends on the host-supplied
 *  `stepperEnabled`, `send`, `sendFrameState`, `applyFrameFit`. */
const STEPPER_SETUP_JS = `
  const stepperSelector = typeof stepperControl?.selector === 'string' && stepperControl.selector.trim() ? stepperControl.selector.trim() : ${JSON.stringify(FRAME_STEPPER_FALLBACK_SELECTOR)};
  const stepperLabel = typeof stepperControl?.label === 'string' && stepperControl.label.trim() ? stepperControl.label.trim().slice(0, 40) : 'Frame';
  let activeFrameIndex = 0;
  let lastActivityAt = 0;
  const frames = () => {
    if (!stepperEnabled) return [];
    try { return Array.from(document.querySelectorAll(stepperSelector)).filter((node) => node instanceof HTMLElement); }
    catch { return []; }
  };
  const sendFrameState = () => {
    const allFrames = frames();
    if (!stepperEnabled || allFrames.length === 0) return;
    send({ type: 'frame-state', index: activeFrameIndex, count: allFrames.length, label: stepperLabel });
  };
  const setActiveFrame = (index) => {
    const allFrames = frames();
    if (!stepperEnabled || allFrames.length === 0) return;
    activeFrameIndex = Math.max(0, Math.min(allFrames.length - 1, Number.isFinite(index) ? Math.round(index) : 0));
    allFrames.forEach((frame, frameIndex) => {
      const active = frameIndex === activeFrameIndex;
      frame.classList.toggle('active', active);
      frame.toggleAttribute('data-studiograph-active-frame', active);
      frame.setAttribute('aria-hidden', active ? 'false' : 'true');
    });
    try { history.replaceState(null, '', '#' + String(activeFrameIndex + 1)); } catch {}
    applyFrameFit();
    sendFrameState();
  };
  const initializeStepper = () => {
    if (!stepperEnabled) return;
    const allFrames = frames();
    if (allFrames.length === 0) return;
    const activeIndex = allFrames.findIndex((frame) => frame.classList.contains('active') || frame.hasAttribute('data-studiograph-active-frame'));
    const hashIndex = /^#\\d+$/.test(location.hash) ? Number(location.hash.slice(1)) - 1 : -1;
    setActiveFrame(hashIndex >= 0 ? hashIndex : activeIndex >= 0 ? activeIndex : 0);
  };
  const stepFrame = (direction) => {
    const allFrames = frames();
    if (allFrames.length === 0) return;
    setActiveFrame((activeFrameIndex + direction + allFrames.length) % allFrames.length);
  };
  const sendActivity = () => {
    if (!stepperEnabled) return;
    const now = Date.now();
    if (now - lastActivityAt < 150) return;
    lastActivityAt = now;
    send({ type: 'activity' });
  };`;
/** Keyboard frame navigation (arrows / page / space / home / end / 1–9). */
const STEPPER_KEYBOARD_JS = `
  window.addEventListener('keydown', (event) => {
    if (!stepperEnabled) return;
    sendActivity();
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') { stepFrame(1); event.preventDefault(); return; }
    if (event.key === 'ArrowLeft' || event.key === 'PageUp' || event.key === 'Backspace') { stepFrame(-1); event.preventDefault(); return; }
    if (event.key === 'Home') { setActiveFrame(0); event.preventDefault(); return; }
    if (event.key === 'End') { setActiveFrame(frames().length - 1); event.preventDefault(); return; }
    if (/^[1-9]$/.test(event.key)) { setActiveFrame(Number(event.key) - 1); event.preventDefault(); }
  });`;
/** Click-to-step: left fifth steps back, the rest forward — unless the click
 *  landed on something interactive. Returns true when it consumed the click. */
const STEPPER_CLICK_JS = `
  const handleStepperClick = (event) => {
    if (!stepperEnabled) return false;
    if (event.target instanceof Element && event.target.closest(interactiveSelector + ',[data-artifact-no-stepper]')) return false;
    const allFrames = frames();
    if (allFrames.length <= 1) return false;
    stepFrame(event.clientX < window.innerWidth * 0.2 ? -1 : 1);
    event.preventDefault();
    return true;
  };`;
const INTERACTIVE_SELECTOR_JS = `const interactiveSelector = 'a,button,input,textarea,select,summary,[contenteditable="true"],[role="button"],[data-artifact-no-pan]';`;
/** The full interactive runtime for a hosted iframe render: postMessage state,
 *  pan/zoom, element selection, content-size reporting, the FOUT guard, plus
 *  the shared stepper. Behaviour is gated at runtime on the baked
 *  `artifactContext` so a single emitted script serves editor + fullscreen. */
function iframeRuntimeScript(artifactId, context, tool, stepperControl, viewportMode) {
    return `
(() => {
  const artifactId = ${JSON.stringify(artifactId)};
  const artifactContext = ${JSON.stringify(context)};
  let artifactTool = ${JSON.stringify(tool)};
  const stepperControl = ${JSON.stringify(stepperControl)};
  const artifactViewportMode = ${JSON.stringify(viewportMode)};
  window.__STUDIOGRAPH_ARTIFACT_CONTEXT__ = artifactContext;
  window.__STUDIOGRAPH_ARTIFACT_TOOL__ = artifactTool;
  document.documentElement.dataset.artifactContext = artifactContext;
  document.documentElement.dataset.artifactTool = artifactTool;
  if (stepperControl) document.documentElement.dataset.artifactStepper = 'true';
  // FOUT guard (fullscreen/present only): @font-face with font-display:swap
  // paints fallback text first, then swaps — a visible flash on the share
  // page. The EDITOR canvas never shows it because the host holds the reveal
  // for the settled report (which waits for document.fonts.ready); fullscreen
  // has no host hold, so hold INSIDE the sandbox: keep the document invisible
  // until fonts settle after load, with hard ceilings so a slow or broken
  // font can only ever delay the reveal briefly (blank-on-backdrop beats
  // wrong-typeface-then-jump). visibility (not display) keeps layout running
  // so font loads actually start while hidden.
  if (artifactContext === 'fullscreen' && document.fonts && typeof document.fonts.ready?.then === 'function') {
    const holdStyle = document.createElement('style');
    holdStyle.textContent = 'html { visibility: hidden !important; }';
    document.head.appendChild(holdStyle);
    let revealed = false;
    const reveal = () => {
      if (revealed) return;
      revealed = true;
      holdStyle.remove();
    };
    // Absolute ceiling — reveal no matter what (load never firing, a hung
    // font request, an exotic fonts.ready rejection).
    setTimeout(reveal, 4000);
    const settle = () => {
      // After load the stylesheets are applied and text is laid out, so the
      // @font-face loads are in flight and fonts.ready is meaningful (called
      // earlier it can resolve before any font load has started). The head
      // carries rel=preload for every referenced font, so cold-load fetches
      // started at parse time — this race usually resolves via fonts.ready.
      Promise.race([
        document.fonts.ready,
        new Promise((resolve) => setTimeout(resolve, 2500)),
      ]).then(reveal, reveal);
    };
    if (document.readyState === 'complete') settle();
    else window.addEventListener('load', settle, { once: true });
  }
  let panPointerId = null;
  const selectedElements = new Map();
  const send = (payload) => {
    try { parent.postMessage({ channel: 'studiograph-artifact', artifactId, ...payload }, '*'); } catch {}
  };
  let pendingPanMove = null;
  let pendingPanMoveFrame = null;
  let pendingPanDelta = null;
  let pendingPanDeltaFrame = null;
  let pendingZoomDelta = null;
  let pendingZoomDeltaFrame = null;
  const flushPanMove = () => {
    if (pendingPanMoveFrame !== null) {
      cancelAnimationFrame(pendingPanMoveFrame);
      pendingPanMoveFrame = null;
    }
    if (!pendingPanMove) return;
    send(pendingPanMove);
    pendingPanMove = null;
  };
  const queuePanMove = (payload) => {
    pendingPanMove = payload;
    if (pendingPanMoveFrame !== null) return;
    pendingPanMoveFrame = requestAnimationFrame(() => {
      pendingPanMoveFrame = null;
      if (!pendingPanMove) return;
      send(pendingPanMove);
      pendingPanMove = null;
    });
  };
  const queuePanDelta = (dx, dy) => {
    pendingPanDelta = pendingPanDelta
      ? { dx: pendingPanDelta.dx + dx, dy: pendingPanDelta.dy + dy }
      : { dx, dy };
    if (pendingPanDeltaFrame !== null) return;
    pendingPanDeltaFrame = requestAnimationFrame(() => {
      pendingPanDeltaFrame = null;
      if (!pendingPanDelta) return;
      const delta = pendingPanDelta;
      pendingPanDelta = null;
      send({ type: 'pan-delta', dx: delta.dx, dy: delta.dy });
    });
  };
  const queueZoomDelta = (delta, x, y) => {
    pendingZoomDelta = pendingZoomDelta
      ? { delta: pendingZoomDelta.delta + delta, x, y }
      : { delta, x, y };
    if (pendingZoomDeltaFrame !== null) return;
    pendingZoomDeltaFrame = requestAnimationFrame(() => {
      pendingZoomDeltaFrame = null;
      if (!pendingZoomDelta) return;
      const zoomDelta = pendingZoomDelta;
      pendingZoomDelta = null;
      send({ type: 'zoom-delta', delta: zoomDelta.delta, x: zoomDelta.x, y: zoomDelta.y });
    });
  };
  const setArtifactTool = (nextTool) => {
    if (nextTool !== 'pointer' && nextTool !== 'hand') return;
    artifactTool = nextTool;
    window.__STUDIOGRAPH_ARTIFACT_TOOL__ = artifactTool;
    document.documentElement.dataset.artifactTool = artifactTool;
    if (artifactTool !== 'hand') {
      panPointerId = null;
      flushPanMove();
      document.documentElement.classList.remove('studiograph-artifact-panning');
    }
  };
  window.addEventListener('error', (event) => {
    send({ status: 'error', message: String(event.message || 'Artifact error') });
  });
  window.addEventListener('unhandledrejection', (event) => {
    send({ status: 'error', message: String(event.reason?.message || event.reason || 'Artifact promise rejection') });
  });
  window.addEventListener('load', () => {
    send({ status: 'ready' });
    initializeStepper();
    startContentSizeReporting();
    scheduleFinalContentSizeReport();
  });
  // Content-size reporting — editor context, fixed-viewport bundles only (the
  // host's measured-geometry path is scoped the same way; responsive bundles
  // just echo whatever size they're given, so reports would be noise). The
  // host sizes the editor iframe and overview zoom from what actually
  // rendered, not the declared viewport (a deck's editor contact sheet
  // renders far larger than it declares). The FIRST report goes out at
  // DOMContentLoaded — the host holds the initial reveal for it, and waiting
  // for window 'load' would hold a blank canvas behind slow images whose
  // layout extent is already known. Layout that settles later (fonts, images
  // without declared dimensions, script-built content) follows through the
  // debounced ResizeObserver. Sub-dead-band wiggle is dropped here (the host
  // applies the same shared dead-band).
  let lastContentSize = null;
  let contentSizeTimer = null;
  let contentSizeReportingStarted = false;
  // Single gate for BOTH the streaming reporter and the settled (final)
  // report — widening one without the other would arm the ResizeObserver
  // loop while never sending the final marker the host's reveal hold waits
  // for (every open would ride the full ceiling).
  const contentSizeReportingEnabled = artifactContext === 'editor' && ${JSON.stringify('fixed')} === artifactViewportMode;
  const measureContentSize = () => ({
    width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0),
    height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0),
  });
  const reportContentSize = () => {
    const size = measureContentSize();
    if (!(size.width > 0) || !(size.height > 0)) return;
    if (lastContentSize && Math.abs(size.width - lastContentSize.width) < ${CONTENT_SIZE_DEAD_BAND_PX} && Math.abs(size.height - lastContentSize.height) < ${CONTENT_SIZE_DEAD_BAND_PX}) return;
    lastContentSize = size;
    send({ type: 'content-size', width: size.width, height: size.height });
  };
  const queueContentSizeReport = () => {
    if (contentSizeTimer) clearTimeout(contentSizeTimer);
    contentSizeTimer = setTimeout(reportContentSize, 150);
  };
  // The once-per-render SETTLED report. Early reports (DOMContentLoaded, RO)
  // can undershoot — images finish and web fonts swap AFTER them, growing the
  // layout. The host holds the initial reveal for this marker so the first
  // visible paint is already at the final size; it goes out after window
  // 'load' + fonts.ready + a quiet beat, unconditionally (the dead-band must
  // not swallow the marker even when the size already matches).
  let finalContentSizeReported = false;
  const reportFinalContentSize = () => {
    if (finalContentSizeReported) return;
    const size = measureContentSize();
    if (!(size.width > 0) || !(size.height > 0)) return;
    finalContentSizeReported = true;
    lastContentSize = size;
    send({ type: 'content-size', width: size.width, height: size.height, final: true });
  };
  const scheduleFinalContentSizeReport = () => {
    if (!contentSizeReportingEnabled) return;
    const settle = () => setTimeout(reportFinalContentSize, 180);
    try {
      const fontsReady = document.fonts?.ready;
      if (fontsReady && typeof fontsReady.then === 'function') fontsReady.then(settle, settle);
      else settle();
    } catch { settle(); }
  };
  const startContentSizeReporting = () => {
    if (contentSizeReportingStarted) return;
    if (!contentSizeReportingEnabled) return;
    contentSizeReportingStarted = true;
    reportContentSize();
    try {
      const observer = new ResizeObserver(queueContentSizeReport);
      observer.observe(document.documentElement);
      if (document.body) observer.observe(document.body);
    } catch {}
    try { document.fonts?.ready?.then(queueContentSizeReport); } catch {}
  };
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => startContentSizeReporting());
  } else {
    startContentSizeReporting();
  }
  const stepperEnabled = artifactContext === 'fullscreen' && !!stepperControl;
  // The host owns scaling in an iframe render — the runtime never self-fits.
  const applyFrameFit = () => {};
${STEPPER_SETUP_JS}
${INTERACTIVE_SELECTOR_JS}
${STEPPER_CLICK_JS}
${STEPPER_KEYBOARD_JS}
  window.addEventListener('message', (event) => {
    const data = event.data;
    if (!data || typeof data !== 'object') return;
    if (data.channel !== 'studiograph-artifact-host' || data.artifactId !== artifactId) return;
    if (data.type === 'set-tool') setArtifactTool(data.tool);
    if (data.type === 'clear-selection') clearSelection();
    if (data.type === 'set-frame') setActiveFrame(Number(data.index));
    if (data.type === 'reset-frames') setActiveFrame(0);
    if (data.type === 'request-frame-state') sendFrameState();
  });
  window.addEventListener('pointermove', sendActivity, { passive: true });
  const canPanFrom = (target) => {
    if (artifactContext !== 'editor' || artifactTool !== 'hand') return false;
    if (!(target instanceof Element)) return false;
    if (target.closest('[data-artifact-no-pan-zone]')) return false;
    return true;
  };
  const canWheelPanFrom = (target) => {
    if (artifactContext !== 'editor') return false;
    if (!(target instanceof Element)) return false;
    if (target.closest('[data-artifact-no-pan-zone]')) return false;
    return true;
  };
  const sendPan = (phase, event) => {
    if (phase === 'start') document.documentElement.classList.add('studiograph-artifact-panning');
    if (phase === 'end' || phase === 'cancel') document.documentElement.classList.remove('studiograph-artifact-panning');
    const payload = {
      type: 'pan',
      phase,
      pointerId: String(event.pointerId),
      x: event.screenX,
      y: event.screenY
    };
    if (phase === 'move') {
      queuePanMove(payload);
      return;
    }
    if (phase === 'end' || phase === 'cancel') flushPanMove();
    send(payload);
  };
  const normalizeWheelDelta = (event) => {
    const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? window.innerHeight : 1;
    return { dx: event.deltaX * unit, dy: event.deltaY * unit };
  };
  const safeText = (value, limit = 160) => String(value || '').replace(/\\s+/g, ' ').trim().slice(0, limit);
  const artifactFrameSelector = ${JSON.stringify(FRAME_SELECTION_SELECTOR)};
  const artifactScaffoldSelector = '[data-artifact-layout],[data-artifact-pan-surface],[data-artifact-scaffold],[data-artifact-container]';
  const artifactNoSelectSelector = '[data-artifact-no-select]';
  const rootRenderableSelector = artifactFrameSelector + ',svg,canvas,img,video,[data-artifact-selectable]';
  const selectableCandidateSelector = artifactFrameSelector + ',h1,h2,h3,h4,h5,h6,p,li,td,th,blockquote,figure,figcaption,label,section,article,main,svg,path,g,rect,circle,text,a,button,[role="button"],div,span';
  const cssEscape = (value) => {
    if (window.CSS?.escape) return window.CSS.escape(value);
    return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');
  };
  const selectorFor = (element) => {
    if (!(element instanceof Element)) return '';
    if (element.id) return '#' + cssEscape(element.id);
    const parts = [];
    let node = element;
    while (node && node.nodeType === 1 && parts.length < 5) {
      let part = node.tagName.toLowerCase();
      const artifactIdAttr = node.getAttribute('data-artifact-id') || node.getAttribute('data-id');
      if (artifactIdAttr) {
        part += '[data-artifact-id="' + artifactIdAttr.replace(/"/g, '\\\\"') + '"]';
        parts.unshift(part);
        break;
      }
      const classes = Array.from(node.classList || []).slice(0, 2);
      if (classes.length) part += '.' + classes.map(cssEscape).join('.');
      const parent = node.parentElement;
      if (parent) {
        const siblings = Array.from(parent.children).filter((child) => child.tagName === node.tagName);
        if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
      }
      parts.unshift(part);
      node = parent;
      if (node === document.body || node === document.documentElement) break;
    }
    return parts.join(' > ');
  };
  const selectionKeyFor = (target) => [target.frameId || '', target.selector || '', target.text || ''].join('::');
  const frameFor = (element) => element.closest?.(artifactFrameSelector) || null;
  const frameDescriptorFor = (element) => {
    const frame = frameFor(element);
    if (!(frame instanceof Element)) return {};
    let frameIndex = -1;
    try { frameIndex = Array.from(document.querySelectorAll(artifactFrameSelector)).indexOf(frame); } catch {}
    const frameName = safeText(
      frame.getAttribute('data-sg-name')
        || frame.getAttribute('data-title')
        || frame.getAttribute('aria-label')
        || frame.getAttribute('title')
        || ''
    , 80);
    const frameStableId = safeText(
      frame.getAttribute('data-sg-id')
        || frame.getAttribute('data-artifact-id')
        || frame.id
        || frame.getAttribute('data-index')
        || ''
    , 80);
    return {
      frameId: frameStableId || (frameIndex >= 0 ? 'frame-' + String(frameIndex + 1) : undefined),
      frameIndex: frameIndex >= 0 ? frameIndex : undefined,
      frameName: frameName || undefined
    };
  };
  const targetDescriptorFor = (element, event) => {
    const rect = element.getBoundingClientRect();
    const selector = selectorFor(element);
    const name = safeText(
      element.getAttribute('data-sg-name')
        || element.getAttribute('aria-label')
        || element.getAttribute('title')
        || ''
    , 80);
    const role = safeText(
      element.getAttribute('data-sg-role')
        || element.getAttribute('role')
        || ''
    , 80);
    const stableId = safeText(
      element.getAttribute('data-sg-id')
        || element.getAttribute('data-artifact-id')
        || element.getAttribute('data-id')
        || element.id
        || ''
    , 80);
    const target = {
      ...frameDescriptorFor(element),
      targetId: '',
      selector,
      tagName: element.tagName.toLowerCase(),
      id: element.id || '',
      className: typeof element.className === 'string' ? element.className : '',
      name: name || undefined,
      role: role || undefined,
      text: safeText(element.textContent),
      x: event.clientX,
      y: event.clientY,
      rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
    };
    target.targetId = stableId || selectionKeyFor(target);
    return target;
  };
  const publishSelection = () => {
    const targets = Array.from(selectedElements.values()).map((entry) => entry.target);
    send({
      type: 'selection-change',
      primaryTargetId: targets[targets.length - 1]?.targetId || '',
      targets
    });
    if (targets.length === 0) send({ type: 'clear-selection' });
  };
  const clearSelection = (publish = true) => {
    for (const entry of selectedElements.values()) {
      entry.element?.removeAttribute?.('data-studiograph-selected-target');
    }
    selectedElements.clear();
    if (publish) publishSelection();
  };
  const isArtifactFrameElement = (element) => element.matches(artifactFrameSelector);
  const isAdditiveSelectionEvent = (event) => !!(event.shiftKey || event.metaKey || event.ctrlKey);
  const isArtifactScaffold = (element) => {
    if (!(element instanceof Element)) return false;
    if (element.matches('[data-artifact-selectable]')) return false;
    if (element === document.body || element === document.documentElement) return true;
    if (isArtifactFrameElement(element)) return false;
    if (element.matches(artifactScaffoldSelector)) return true;
    if (element.querySelector(artifactFrameSelector)) return true;
    if (element.parentElement === document.body && element.children.length > 0 && !element.matches(rootRenderableSelector)) return true;
    const rect = element.getBoundingClientRect();
    const bodyRect = document.body?.getBoundingClientRect?.();
    const bodyWidth = Math.max(document.documentElement.clientWidth || 0, document.body?.scrollWidth || 0, bodyRect?.width || 0);
    const bodyHeight = Math.max(document.documentElement.clientHeight || 0, document.body?.scrollHeight || 0, bodyRect?.height || 0);
    return element.children.length > 0
      && !element.matches(rootRenderableSelector)
      && rect.width >= bodyWidth - 2
      && rect.height >= bodyHeight - 2;
  };
  const selectionElementFor = (target, event) => {
    if (!(target instanceof Element)) return null;
    const explicitSelectable = target.closest('[data-artifact-selectable]');
    const explicitNoSelect = target.closest(artifactNoSelectSelector);
    if (explicitNoSelect && !explicitSelectable) return null;
    const element = explicitSelectable || target.closest(selectableCandidateSelector) || target;
    if (!(element instanceof Element)) return null;
    if (!explicitSelectable && isArtifactScaffold(element)) return null;
    return element;
  };
  const shouldSuppressNativeModifierSelection = (event) => {
    if (artifactContext !== 'editor' || artifactTool !== 'pointer') return false;
    if (!isAdditiveSelectionEvent(event)) return false;
    if (event.button !== undefined && event.button !== 0) return false;
    return selectionElementFor(event.target, event) instanceof Element;
  };
  const selectElement = (target, event) => {
    if (artifactContext !== 'editor' || artifactTool !== 'pointer') return;
    if (!(target instanceof Element)) return;
    const element = selectionElementFor(target, event);
    if (!(element instanceof Element)) {
      clearSelection();
      return;
    }
    const targetDescriptor = targetDescriptorFor(element, event);
    const key = targetDescriptor.targetId;
    const additive = isAdditiveSelectionEvent(event);
    if (!additive) clearSelection(false);
    if (additive && selectedElements.has(key)) {
      selectedElements.get(key)?.element?.removeAttribute?.('data-studiograph-selected-target');
      selectedElements.delete(key);
    } else {
      element.setAttribute('data-studiograph-selected-target', 'true');
      selectedElements.set(key, { element, target: targetDescriptor });
    }
    publishSelection();
    if (additive) event.preventDefault();
  };
  window.addEventListener('keydown', (event) => {
    if (artifactContext !== 'editor') return;
    if (event.key !== 'Escape' || selectedElements.size === 0) return;
    clearSelection();
    event.preventDefault();
  }, { capture: true });
  // Prototypes frequently contain links (href="#cart", "checkout.html", external URLs)
  // that, inside this sandboxed srcdoc frame, navigate the frame to a nonexistent target
  // and leave a blank canvas. Handle them gracefully: scroll to in-page anchors when they
  // exist, and otherwise swallow the navigation so the prototype stays put. JS-driven
  // links (javascript: / onclick handlers) still run — we only cancel the default nav.
  const handleArtifactLinkClick = (event) => {
    if (event.defaultPrevented) return false;
    if (event.button !== undefined && event.button !== 0) return false;
    if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
    const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
    if (!anchor) return false;
    const href = anchor.getAttribute('href') || '';
    if (!href || /^javascript:/i.test(href)) return false;
    if (href.startsWith('#')) {
      // In-page anchor: scroll to it when present, no-op when it dangles. Never navigate.
      event.preventDefault();
      const id = href.slice(1);
      if (id) {
        let dest = null;
        try { dest = document.getElementById(id) || document.querySelector('a[name="' + cssEscape(id) + '"]'); } catch {}
        dest?.scrollIntoView?.({ block: 'start', behavior: 'smooth' });
      }
      return true;
    }
    // Any other navigation (relative path, external URL, mailto, etc.) would blank the
    // sandboxed frame — swallow it rather than leave the user on an empty canvas.
    event.preventDefault();
    return true;
  };
  window.addEventListener('click', (event) => {
    // In the editor, still allow the click to select the link element; just don't navigate.
    if (handleArtifactLinkClick(event) && artifactContext !== 'editor') return;
    if (stepperEnabled) {
      if (handleStepperClick(event)) return;
      if (event.target instanceof Element && event.target.closest(interactiveSelector + ',[data-artifact-no-stepper]')) return;
    }
    selectElement(event.target, event);
  }, { capture: true });
  window.addEventListener('mousedown', (event) => {
    if (!shouldSuppressNativeModifierSelection(event)) return;
    event.preventDefault();
  }, { capture: true });
  window.addEventListener('wheel', (event) => {
    if (!canWheelPanFrom(event.target)) return;
    const delta = normalizeWheelDelta(event);
    if (Math.abs(delta.dx) < 0.01 && Math.abs(delta.dy) < 0.01) return;
    if (event.ctrlKey || event.metaKey) {
      queueZoomDelta(delta.dy, event.clientX, event.clientY);
      event.preventDefault();
      return;
    }
    // In the editor the canvas is a pan/zoom surface (this handler only runs in
    // editor context — see canWheelPanFrom): a trackpad/wheel gesture pans the
    // whole canvas, it never scrolls a container the artifact happens to sit in.
    // An artifact that genuinely needs an internal scroll region marks it with
    // [data-artifact-no-pan-zone] (canWheelPanFrom returns false there, so the
    // wheel falls through to native scrolling).
    queuePanDelta(delta.dx, delta.dy);
    event.preventDefault();
  }, { capture: true, passive: false });
  window.addEventListener('pointerdown', (event) => {
    if (event.button !== 0 || !canPanFrom(event.target)) return;
    panPointerId = event.pointerId;
    try { event.target.setPointerCapture?.(event.pointerId); } catch {}
    sendPan('start', event);
    event.preventDefault();
  }, { capture: true });
  window.addEventListener('pointermove', (event) => {
    if (panPointerId !== event.pointerId) return;
    sendPan('move', event);
    event.preventDefault();
  }, { capture: true });
  const endPan = (event) => {
    if (panPointerId !== event.pointerId) return;
    sendPan(event.type === 'pointercancel' ? 'cancel' : 'end', event);
    panPointerId = null;
  };
  window.addEventListener('pointerup', endPan, { capture: true });
  window.addEventListener('pointercancel', endPan, { capture: true });
})();`;
}
/** The minimal self-contained runtime for a hostless render (downloaded HTML).
 *  No host to scale or chrome the frame, so it owns step-frames + self-fit and
 *  nothing else — capability-frozen at today's export parity. Only emitted for
 *  a stepped fullscreen export; a paged reader / plain document needs no JS. */
function standaloneRuntimeScript(stepperControl) {
    return `
(() => {
  const stepperControl = ${JSON.stringify(stepperControl)};
  const stepperEnabled = true;
  // No host: the runtime scales the active frame to fit the viewport (contain,
  // centered) so a fixed-size slide (e.g. 1920x1080) isn't clipped by the
  // overflow:hidden container on a smaller window. Gated to the fullscreen
  // context so the export/PDF render keeps frames native-size.
  function fitActiveFrame() {
    if (document.documentElement.dataset.artifactContext !== 'fullscreen') return;
    const active = frames()[activeFrameIndex];
    if (!active) return;
    active.style.setProperty('transform', 'none', 'important');
    const fw = active.offsetWidth || 1;
    const fh = active.offsetHeight || 1;
    const ww = window.innerWidth || fw;
    const wh = window.innerHeight || fh;
    const scale = Math.min(ww / fw, wh / fh);
    const left = Math.max(0, (ww - fw * scale) / 2);
    const top = Math.max(0, (wh - fh * scale) / 2);
    active.style.setProperty('transform-origin', 'top left', 'important');
    active.style.setProperty('transform', 'translate(' + left + 'px, ' + top + 'px) scale(' + scale + ')', 'important');
  }
  // send (and the shared fragment's sendFrameState/sendActivity that call it)
  // post to a host; there is none here, so a no-op send makes them inert.
  const send = () => {};
  const applyFrameFit = fitActiveFrame;
${STEPPER_SETUP_JS}
${INTERACTIVE_SELECTOR_JS}
${STEPPER_CLICK_JS}
${STEPPER_KEYBOARD_JS}
  window.addEventListener('click', (event) => { handleStepperClick(event); }, { capture: true });
  window.addEventListener('resize', fitActiveFrame);
  // Print-to-PDF (Cmd+P / Save as PDF on the downloaded file): the on-screen
  // self-fit writes an inline transform: translate(...) scale(...) !important
  // on the active frame, sized to the WINDOW. Inline !important beats any print
  // stylesheet, so that screen transform would carry into print and push the
  // frame off the page (a blank PDF). Clear every frame's inline transform
  // before printing — the @media print rules then lay each frame out one per
  // page at native size — and restore the screen fit afterward.
  const clearFitTransforms = () => { for (const frame of frames()) frame.style.removeProperty('transform'); };
  window.addEventListener('beforeprint', clearFitTransforms);
  window.addEventListener('afterprint', fitActiveFrame);
  try {
    const printMedia = window.matchMedia('print');
    const onPrintChange = (event) => { if (event.matches) clearFitTransforms(); else fitActiveFrame(); };
    if (printMedia.addEventListener) printMedia.addEventListener('change', onPrintChange);
    else if (printMedia.addListener) printMedia.addListener(onPrintChange);
  } catch {}
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializeStepper, { once: true });
  else initializeStepper();
})();`;
}
function addArtifactContextToHtmlTag(html, context, tool, hasStepper) {
    if (!/<html[\s>]/i.test(html))
        return html;
    return html.replace(/<html\b([^>]*)>/i, (match, attrs) => {
        let nextAttrs = attrs;
        if (!/\bdata-artifact-context\s*=/i.test(nextAttrs))
            nextAttrs = ` data-artifact-context="${context}"${nextAttrs}`;
        if (!/\bdata-artifact-tool\s*=/i.test(nextAttrs))
            nextAttrs = ` data-artifact-tool="${tool}"${nextAttrs}`;
        if (hasStepper && !/\bdata-artifact-stepper\s*=/i.test(nextAttrs))
            nextAttrs = ` data-artifact-stepper="true"${nextAttrs}`;
        return `<html${nextAttrs}>`;
    });
}
function addAttributesToHtmlTag(html, attrs) {
    if (!/<html[\s>]/i.test(html))
        return html;
    return html.replace(/<html\b([^>]*)>/i, (match, existingAttrs) => {
        let nextAttrs = existingAttrs;
        for (const [name, value] of Object.entries(attrs)) {
            if (value === undefined || value === false)
                continue;
            if (new RegExp(`\\b${name}\\s*=`, 'i').test(nextAttrs))
                continue;
            nextAttrs = ` ${name}${value === true ? '' : `="${escapeAttribute(value)}"`}${nextAttrs}`;
        }
        return `<html${nextAttrs}>`;
    });
}
// ---------------------------------------------------------------------------
// Host styles. The fullscreen/present + paged blocks are shared between the
// in-app (iframe) and the export (none) host-style packages so the two can't
// drift on which elements letterbox / step. The surface-specific pieces
// (editor cursor for iframe; export-frame flattening + print pagination +
// in-document present backdrop for none) stay where they belong.
// ---------------------------------------------------------------------------
function stepperFullscreenCss(bundle) {
    if (!getStepperControl(bundle))
        return '';
    return `
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"],
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] body {
  width: 100% !important;
  height: 100% !important;
  min-height: 100% !important;
  overflow: hidden !important;
}
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] .artifact-sequence,
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] .deck,
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] [data-artifact-layout="sequence"] {
  width: 100% !important;
  height: 100% !important;
  min-height: 100% !important;
  padding: 0 !important;
  margin: 0 !important;
  display: block !important;
  position: relative !important;
  overflow: hidden !important;
  background: transparent !important;
  box-shadow: none !important;
}
${frameStepperVisibilityCss(frameStepperSelector(getStepperControl(bundle)?.selector), 'html[data-artifact-context="fullscreen"][data-artifact-stepper="true"]')}
`;
}
// `pinHtmlHeight`: the in-app (iframe) present pins html/body to the host stage
// height (the stage sizes the iframe, so 100% fills it). The hostless export
// must NOT — a downloaded paged document has no host stage, and a fixed
// `height: 100%` body turns the page column into an internal scroll container
// (and would print only the first viewport when the file is printed to PDF).
// It keeps `min-height: 100%` so the document grows to natural content height
// and the WINDOW scrolls — exactly the pre-unification per-surface behavior.
function pagedFullscreenCss(bundle, pinHtmlHeight) {
    if (bundle.viewport.mode !== 'paged')
        return '';
    return `
html[data-artifact-context="fullscreen"],
html[data-artifact-context="fullscreen"] body {
  width: 100% !important;
${pinHtmlHeight ? '  height: 100% !important;\n' : ''}  min-height: 100% !important;
  overflow: auto !important;
}
html[data-artifact-context="fullscreen"] .pages,
html[data-artifact-context="fullscreen"] .document-board,
html[data-artifact-context="fullscreen"] .artifact-board[data-artifact-layout="paged"],
html[data-artifact-context="fullscreen"] [data-artifact-layout="paged"] {
  width: 100% !important;
  height: auto !important;
  min-height: 100% !important;
  padding: 48px 0 !important;
  display: flex !important;
  flex-direction: column !important;
  align-items: center !important;
  justify-content: flex-start !important;
  align-content: center !important;
  gap: 32px !important;
  background: transparent !important;
  box-shadow: none !important;
  overflow: visible !important;
}
html[data-artifact-context="fullscreen"] .page,
html[data-artifact-context="fullscreen"] [data-artifact-page] {
  position: relative !important;
  left: auto !important;
  right: auto !important;
  top: auto !important;
  bottom: auto !important;
  transform: none !important;
}
`;
}
/** Host styles for the in-app (iframe) render: transparent background so the
 *  host stage shows through, the editor hand/pointer cursors, plus the shared
 *  fullscreen stepper / paged reader CSS. */
// No `tool` param: the hand/grab cursor CSS keys entirely off the runtime-set
// `[data-artifact-tool="hand"]` attribute (the runtime flips it live on tool
// change), never off a build-time tool value — so build-time tool can't affect
// these styles.
function inAppHostStyleTags(bundle, context) {
    const stepperFullscreen = context === 'fullscreen' ? stepperFullscreenCss(bundle) : '';
    const editorCursor = context === 'editor'
        ? `
html[data-artifact-context="editor"][data-artifact-tool="hand"],
html[data-artifact-context="editor"][data-artifact-tool="hand"] body,
html[data-artifact-context="editor"][data-artifact-tool="hand"] body * {
  cursor: grab !important;
}
html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning,
html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning body,
html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning body * {
  cursor: grabbing !important;
}
html[data-artifact-context="editor"][data-artifact-tool="hand"] a,
html[data-artifact-context="editor"][data-artifact-tool="hand"] button,
html[data-artifact-context="editor"][data-artifact-tool="hand"] summary,
html[data-artifact-context="editor"][data-artifact-tool="hand"] [role="button"] {
  cursor: pointer !important;
}
html[data-artifact-context="editor"][data-artifact-tool="hand"] input,
html[data-artifact-context="editor"][data-artifact-tool="hand"] textarea,
html[data-artifact-context="editor"][data-artifact-tool="hand"] select,
html[data-artifact-context="editor"][data-artifact-tool="hand"] [contenteditable="true"],
html[data-artifact-context="editor"][data-artifact-tool="hand"] [data-artifact-no-pan] {
  cursor: auto !important;
}
`
        : '';
    const pagedFullscreen = context === 'fullscreen' ? pagedFullscreenCss(bundle, true) : '';
    return `<style data-artifact-host>
html, body {
  background: transparent !important;
}
.document-board,
.artifact-board[data-artifact-layout="paged"] {
  background: transparent !important;
}
${editorCursor}
${pagedFullscreen}
${stepperFullscreen}
</style>`;
}
function artifactRenderHostStyleTags(bundle) {
    const viewport = bundle.display?.fullscreenViewport ?? bundle.viewport;
    const frameWidth = `${Math.max(1, Math.round(viewport.width))}px`;
    const frameHeight = `${Math.max(1, Math.round(viewport.height))}px`;
    const authoredPageSizing = bundle.viewport.mode === 'paged';
    const exportFrameSizing = authoredPageSizing
        ? '  contain: layout paint !important;'
        : `  width: var(--studiograph-export-frame-width) !important;
  height: var(--studiograph-export-frame-height) !important;
  min-width: var(--studiograph-export-frame-width) !important;
  min-height: var(--studiograph-export-frame-height) !important;
  max-width: var(--studiograph-export-frame-width) !important;
  max-height: var(--studiograph-export-frame-height) !important;
  contain: layout paint size !important;`;
    const exportFlattening = `
html[data-artifact-context="export"] {
  --studiograph-export-frame-width: ${frameWidth};
  --studiograph-export-frame-height: ${frameHeight};
}
html[data-artifact-context="export"],
html[data-artifact-context="export"] body {
  width: 100% !important;
  min-height: 100% !important;
  height: auto !important;
  overflow: visible !important;
}
html[data-artifact-context="export"] .artifact-sequence,
html[data-artifact-context="export"] .deck,
html[data-artifact-context="export"] .pages,
html[data-artifact-context="export"] .document-board,
html[data-artifact-context="export"] .artifact-board,
html[data-artifact-context="export"] [data-artifact-layout="sequence"],
html[data-artifact-context="export"] [data-artifact-layout="paged"] {
  width: 100% !important;
  height: auto !important;
  min-height: 0 !important;
  padding: 0 !important;
  margin: 0 !important;
  display: flex !important;
  flex-direction: column !important;
  align-items: center !important;
  justify-content: flex-start !important;
  gap: 0 !important;
  overflow: visible !important;
  background: transparent !important;
  box-shadow: none !important;
}
html[data-artifact-context="export"] [data-artifact-frame],
html[data-artifact-context="export"] [data-artifact-page],
html[data-artifact-context="export"] .page {
  display: block !important;
  position: relative !important;
  box-sizing: border-box !important;
${exportFrameSizing}
  isolation: isolate !important;
  clip-path: inset(0) !important;
  inset: auto !important;
  left: auto !important;
  top: auto !important;
  right: auto !important;
  bottom: auto !important;
  transform: none !important;
  margin: 0 !important;
  flex: none !important;
  overflow: hidden !important;
}
`;
    // The hosted app presents artifacts on a neutral gray stage
    // (.artifact-stage[data-view-mode='fullscreen'] in ArtifactEntityView.svelte
    // — ARTIFACT_PRESENT_BACKDROP, keep in sync). The exported standalone file
    // has no host stage, so the document itself carries the backdrop — but ONLY
    // where the standalone runtime letterboxes content around a stage (stepper
    // present mode, paged reader). A plain fixed/responsive document fills the
    // window with its own page, and forcing its body transparent would strip an
    // AUTHORED background the in-app render keeps. `display.background:
    // 'transparent'` opts out, matching the in-app rule.
    const backdropScope = bundle.display?.controls?.stepper
        ? 'html[data-artifact-context="fullscreen"][data-artifact-stepper="true"]'
        : bundle.viewport.mode === 'paged'
            ? 'html[data-artifact-context="fullscreen"]'
            : null;
    const presentBackdrop = backdropScope && bundle.display?.background !== 'transparent'
        ? `
${backdropScope} {
  background: ${ARTIFACT_PRESENT_BACKDROP} !important;
}
${backdropScope} body {
  background: transparent !important;
}
`
        : '';
    const stepperFullscreen = stepperFullscreenCss(bundle);
    const pagedFullscreen = pagedFullscreenCss(bundle, false);
    const printPagination = `
@media print {
  html[data-artifact-context="fullscreen"],
  html[data-artifact-context="fullscreen"] body,
  html[data-artifact-context="export"],
  html[data-artifact-context="export"] body {
    height: auto !important;
    min-height: 0 !important;
    overflow: visible !important;
    /* The on-screen present backdrop must never print as gray pages. */
    background: transparent !important;
  }
  html[data-artifact-context="fullscreen"] .artifact-sequence,
  html[data-artifact-context="fullscreen"] .deck,
  html[data-artifact-context="fullscreen"] .pages,
  html[data-artifact-context="fullscreen"] .document-board,
  html[data-artifact-context="fullscreen"] [data-artifact-layout="sequence"],
  html[data-artifact-context="fullscreen"] [data-artifact-layout="paged"],
  html[data-artifact-context="export"] .artifact-sequence,
  html[data-artifact-context="export"] .deck,
  html[data-artifact-context="export"] .pages,
  html[data-artifact-context="export"] .document-board,
  html[data-artifact-context="export"] .artifact-board,
  html[data-artifact-context="export"] [data-artifact-layout="sequence"],
  html[data-artifact-context="export"] [data-artifact-layout="paged"] {
    display: block !important;
    width: 100% !important;
    height: auto !important;
    min-height: 0 !important;
    padding: 0 !important;
    margin: 0 !important;
    overflow: visible !important;
    background: transparent !important;
    box-shadow: none !important;
  }
  html[data-artifact-context="fullscreen"] [data-artifact-frame],
  html[data-artifact-context="fullscreen"] [data-artifact-page],
  html[data-artifact-context="fullscreen"] .page,
  html[data-artifact-context="export"] [data-artifact-frame],
  html[data-artifact-context="export"] [data-artifact-page],
  html[data-artifact-context="export"] .page {
    display: block !important;
    position: relative !important;
    box-sizing: border-box !important;
${exportFrameSizing}
    isolation: isolate !important;
    clip-path: inset(0) !important;
    inset: auto !important;
    left: auto !important;
    top: auto !important;
    right: auto !important;
    bottom: auto !important;
    transform: none !important;
    margin: 0 !important;
    overflow: hidden !important;
    break-after: page;
    page-break-after: always;
  }
  html[data-artifact-context="fullscreen"] [data-artifact-frame]:not(:first-child),
  html[data-artifact-context="fullscreen"] [data-artifact-page]:not(:first-child),
  html[data-artifact-context="fullscreen"] .page:not(:first-child),
  html[data-artifact-context="export"] [data-artifact-frame]:not(:first-child),
  html[data-artifact-context="export"] [data-artifact-page]:not(:first-child),
  html[data-artifact-context="export"] .page:not(:first-child) {
    break-before: page;
    page-break-before: always;
  }
  html[data-artifact-context="fullscreen"] [data-artifact-frame]:last-child,
  html[data-artifact-context="fullscreen"] [data-artifact-page]:last-child,
  html[data-artifact-context="fullscreen"] .page:last-child,
  html[data-artifact-context="export"] [data-artifact-frame]:last-child,
  html[data-artifact-context="export"] [data-artifact-page]:last-child,
  html[data-artifact-context="export"] .page:last-child {
    break-after: auto;
    page-break-after: auto;
  }
}
`;
    // Letterboxed exports (a stepped deck/prototype, a paged document) print each
    // frame as its own page at native size. `preferCSSPageSize:false` in the
    // headless Playwright PDF path ignores this, so it only governs a browser
    // Cmd+P / Save-as-PDF of the downloaded file — exactly the path it fixes.
    const printPageSize = (getStepperControl(bundle) || bundle.viewport.mode === 'paged')
        ? `\n@media print {\n  @page { size: ${frameWidth} ${frameHeight}; margin: 0; }\n  html[data-artifact-context="fullscreen"] {\n    --studiograph-export-frame-width: ${frameWidth};\n    --studiograph-export-frame-height: ${frameHeight};\n  }\n}\n`
        : '';
    // The on-screen present chrome (hide-inactive-frames, letterbox/self-fit
    // scaffolding, gray backdrop) is `@media screen` ONLY. Without this the
    // stepper hide rule out-specifies the print "show every frame" rule and only
    // the active frame would survive a print — and even it lands off-page from
    // the runtime's inline self-fit transform. Confining the present chrome to
    // screen lets the print rules below lay out all frames cleanly, one per page.
    return `<style data-artifact-export-host>
html, body {
  margin: 0;
  padding: 0;
}
@media screen {
${presentBackdrop}
${pagedFullscreen}
${stepperFullscreen}
}
${exportFlattening}
${printPagination}
${printPageSize}
</style>`;
}
function headAdditions(bundle, entry, options) {
    // The CSP is derived from the SAME AssetResolution that rewrites the asset
    // URLs (finalize step), so this document can't carry URLs from one asset
    // model and a policy from another. `base-uri` opens to exactly the
    // baseHref's origin when the headless render injects a <base> tag.
    // Safe unescaped: every dynamic CSP part is validated upstream (origins are
    // URL-parsed, share tokens charset-checked) — no quote can reach here.
    const csp = artifactCsp(options.assets, { allowBaseOrigin: options.baseHref });
    const additions = [
        '<meta charset="utf-8">',
        `<meta http-equiv="Content-Security-Policy" content="${csp}">`,
        options.baseHref ? `<base href="${escapeAttribute(options.baseHref)}">` : '',
        '<meta name="generator" content="Studiograph Artifact Canvas">',
        /<title[\s>]/i.test(entry) ? '' : `<title>${escapeHtml(bundle.title)}</title>`,
        styleTags(bundle),
        options.includeRenderHostStyles ? artifactRenderHostStyleTags(bundle) : '',
    ];
    return additions.filter(Boolean).join('\n');
}
function withDoctype(html) {
    return /^\s*<!doctype\b/i.test(html) ? html : `<!doctype html>\n${html}`;
}
export function artifactExportFilename(bundle, extension = 'html') {
    const source = bundle.title || bundle.artifactId || 'artifact';
    const slug = source
        .normalize('NFKD')
        .replace(/[\u0300-\u036f]/g, '')
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '')
        .slice(0, 80);
    return `${slug || 'artifact'}.${extension}`;
}
/**
 * THE single artifact document builder (eng review D2). Every render context —
 * in-app canvas, public share/present page, downloaded standalone HTML, and the
 * headless Playwright render — produces its HTML here, from one set of
 * content-processing helpers and one runtime. `host` selects the document SHAPE
 * (a live-hosted iframe srcdoc vs a self-contained file) and the runtime's
 * capabilities; the asset CSP + URL rewrite come from the one `assets` value.
 */
export function buildArtifactDocument(bundle, ctx) {
    return ctx.host === 'iframe' ? buildIframeDocument(bundle, ctx) : buildStandaloneDocument(bundle, ctx);
}
/** A self-contained runtime that replaces the browser's default broken-image
 *  glyph with a footprint-preserving placeholder, mirroring the reading
 *  surfaces' missing-asset chip (Carbon DocumentUnknown glyph + the same
 *  "Original asset is no longer available" copy; createUnavailableImageNote in
 *  web/src/lib/utils/unavailable-image.ts). Injected into EVERY render (canvas,
 *  fullscreen, standalone download, headless PNG/PDF) so a deleted or
 *  unreachable embed degrades identically everywhere — and, unlike the inline
 *  reading chip, holds the image's box so a fixed artifact layout (e.g. a slide)
 *  doesn't collapse. Vanilla (the sandboxed iframe can't import the Svelte
 *  builder), theme-agnostic (an artifact background is light OR dark, and the
 *  app's `--color-text-*` tokens aren't defined inside the sandbox), capture
 *  phase (an `<img>` `error` event doesn't bubble), idempotent, never throws. */
function brokenImageScriptTag() {
    const js = `
(() => {
  var MESSAGE = 'Original asset is no longer available';
  var GLYPH = [
    'M16,30H8a2,2,0,0,1-2-2V4A2,2,0,0,1,8,2h10a1,1,0,0,1,.71.29l5,5A1,1,0,0,1,24,8v6H22V9H17a1,1,0,0,1-1-1V4H8V28h8Z',
    'M24.5,29A2.5,2.5,0,1,1,27,26.5,2.5,2.5,0,0,1,24.5,29Zm0-3.5a1,1,0,1,0,1,1A1,1,0,0,0,24.5,25.5Z',
    'M24.5,23a1,1,0,0,1-1-1V21a2.5,2.5,0,1,0-2.5-2.5,1,1,0,0,1-2,0A4.5,4.5,0,1,1,25,22.79V22A1,1,0,0,1,24.5,23Z'
  ];
  var NS = 'http://www.w3.org/2000/svg';
  function replace(img) {
    try {
      if (!img || img.tagName !== 'IMG' || img.hasAttribute('data-sg-broken')) return;
      if (img.complete && img.naturalWidth > 0) return; // recovered (e.g. a retry swapped src)
      img.setAttribute('data-sg-broken', '');
      var cs = window.getComputedStyle ? getComputedStyle(img) : null;
      var rect = img.getBoundingClientRect();
      // getComputedStyle returns a USED px length ("695px") only for a laid-out
      // element. Inside a display:none subtree — an INACTIVE FRAME in present /
      // fullscreen mode — it returns the COMPUTED value verbatim ("100%"),
      // which is not a pixel box; parseFloat("100%") === 100 would bake a bogus
      // 100x100 footprint (the small glyph-only box seen in present mode). So
      // accept a computed length ONLY when it is in px; it still dodges the
      // collapsed broken-img rendered box and the "numeric width attr that CSS
      // overrides" trap. Then a unitless pixel attribute, the rendered box, a
      // default. A percentage means the image fills its slot — mirrored below.
      var pxLen = function (cssVal) {
        if (typeof cssVal === 'string' && /px$/.test(cssVal)) {
          var c = parseFloat(cssVal);
          if (isFinite(c) && c > 1) return Math.round(c);
        }
        return null;
      };
      var dim = function (cssVal, attr, rectVal, dflt) {
        var c = pxLen(cssVal);
        if (c != null) return c;
        if (attr && /^\\s*\\d+(?:\\.\\d+)?\\s*$/.test(attr)) return parseFloat(attr);
        if (rectVal > 1) return Math.round(rectVal);
        return dflt;
      };
      var w = dim(cs && cs.width, img.getAttribute('width'), rect.width, 240);
      var h = dim(cs && cs.height, img.getAttribute('height'), rect.height, 160);
      // Fill case: the computed size is a percentage we could NOT resolve to px
      // (the img lives in a display:none present-mode frame). Mirror the
      // percentage onto the placeholder so it fills the same slot once the frame
      // is revealed + self-fit-scaled, instead of the default box. w/h keep
      // their defaults purely to select the large (glyph+label) tier, since a
      // fill image is content-sized.
      var fillW = (pxLen(cs && cs.width) == null && cs && /%$/.test(cs.width)) ? cs.width : null;
      var fillH = (pxLen(cs && cs.height) == null && cs && /%$/.test(cs.height)) ? cs.height : null;
      var radius = (cs && cs.borderRadius && cs.borderRadius !== '0px') ? cs.borderRadius : '4px';
      // Degrade by available footprint so a tiny broken embed stays tiny instead
      // of blowing out the layout with text it can't hold: large -> glyph +
      // label; small -> glyph only; tiny -> just the box. The aria-label always
      // carries the full message regardless of what is drawn.
      var minDim = w < h ? w : h;
      var showGlyph = minDim >= 40;
      var showText = w >= 130 && h >= 70;
      var pad = showText ? 12 : (showGlyph ? 6 : 0);
      // Block-level images (incl. flex/grid items) get a block-level flex box so
      // full-width layout and margin:auto centering survive; inline images stay
      // inline-flex.
      var disp = (cs && /^inline/.test(cs.display)) ? 'inline-flex' : 'flex';
      var note = document.createElement('div');
      note.setAttribute('data-sg-broken-image', '');
      note.setAttribute('role', 'img');
      var alt = (img.getAttribute('alt') || '').trim();
      note.setAttribute('aria-label', alt ? alt + ' \\u2014 ' + MESSAGE : MESSAGE);
      // width holds the footprint; the text tier uses min-height (not a fixed
      // height) so the label can grow rather than clip, while the glyph/box
      // tiers keep the exact footprint. overflow:hidden only rounds the corners
      // (the grown/exact height always fits whatever is drawn).
      var widthCss = 'width:' + (fillW || (w + 'px')) + ';';
      var heightCss = fillH ? 'height:' + fillH + ';' : (showText ? 'min-height:' + h + 'px;' : 'height:' + h + 'px;');
      note.style.cssText = 'box-sizing:border-box;display:' + disp + ';flex-direction:column;align-items:center;justify-content:center;gap:8px;' + widthCss + heightCss + 'padding:' + pad + 'px;background:rgba(128,128,128,0.12);border:1px solid rgba(128,128,128,0.25);border-radius:' + radius + ';color:rgba(120,120,120,0.95);font:500 13px/1.35 system-ui,-apple-system,sans-serif;text-align:center;overflow:hidden';
      // Preserve the image's placement so a broken absolutely-positioned,
      // floated, centered, or grid/flex-placed image keeps its slot instead of
      // dropping into normal flow and shifting the whole artifact.
      if (cs) {
        var keep = ['position','top','right','bottom','left','transform','transformOrigin','float','marginTop','marginRight','marginBottom','marginLeft','verticalAlign','alignSelf','justifySelf','gridColumn','gridRow'];
        for (var k = 0; k < keep.length; k++) {
          var val = cs[keep[k]];
          if (val && val !== 'auto' && val !== 'normal' && val !== 'static' && val !== 'none' && val !== '0px') {
            try { note.style[keep[k]] = val; } catch (e) {}
          }
        }
      }
      if (showGlyph) {
        var svg = document.createElementNS(NS, 'svg');
        svg.setAttribute('viewBox', '0 0 32 32');
        svg.setAttribute('width', '20');
        svg.setAttribute('height', '20');
        svg.setAttribute('fill', 'currentColor');
        svg.setAttribute('aria-hidden', 'true');
        svg.style.cssText = 'flex:none;opacity:0.85';
        for (var i = 0; i < GLYPH.length; i++) {
          var p = document.createElementNS(NS, 'path');
          p.setAttribute('d', GLYPH[i]);
          svg.appendChild(p);
        }
        note.appendChild(svg);
      }
      if (showText) {
        var label = document.createElement('span');
        label.textContent = MESSAGE;
        label.style.maxWidth = '100%';
        note.appendChild(label);
      }
      var prevDisplay = img.style.display;
      img.style.display = 'none';
      if (img.parentNode) img.parentNode.insertBefore(note, img.nextSibling);
      // Self-heal: if the same <img> later loads (an artifact's own onerror
      // retry swapped src to a working URL), drop the placeholder and restore
      // the image to its PRIOR inline display rather than leave a valid image
      // hidden behind the note.
      img.addEventListener('load', function onload() {
        if (img.naturalWidth > 0) {
          img.removeEventListener('load', onload);
          if (note.parentNode) note.parentNode.removeChild(note);
          img.style.display = prevDisplay;
          img.removeAttribute('data-sg-broken');
        }
      });
    } catch (e) {}
  }
  // Schedule on the window receiver (a detached rAF call can throw in strict
  // contexts) and never let scheduling throw out of the capture listener.
  function defer(fn) {
    try { if (window.requestAnimationFrame) { window.requestAnimationFrame(fn); return; } } catch (e) {}
    try { window.setTimeout(fn, 0); } catch (e) {}
  }
  document.addEventListener('error', function (e) {
    var t = e && e.target;
    // Defer a frame so an artifact's own onerror retry can swap src first; the
    // load-time sweep below still inserts the note synchronously before a
    // headless capture (which waits for load + rAF), so the export never races.
    if (t && t.tagName === 'IMG') defer(function () { replace(t); });
  }, true);
  // Sweep images already broken before the listener registered (cached 404s, or
  // the standalone tag running at body end after parse).
  function sweep() {
    try {
      var imgs = document.querySelectorAll('img');
      for (var i = 0; i < imgs.length; i++) {
        var im = imgs[i];
        if (im.complete && im.naturalWidth === 0 && im.getAttribute('src')) replace(im);
      }
    } catch (e) {}
  }
  // Run once now — catches images already parsed + broken when this tag
  // executes (the standalone tag sits at body end, after the markup), so a
  // user/library script running next sees the placeholder, not the raw broken
  // img. Run again on load for cached 404s that completed before the capture
  // listener registered.
  sweep();
  window.addEventListener('load', sweep);
})();`;
    return `<script data-artifact-broken-image>${escapeScript(js)}</script>`;
}
/** Live-hosted iframe srcdoc (in-app canvas + public share/present page). */
function buildIframeDocument(bundle, ctx) {
    const context = ctx.context ?? 'editor';
    const tool = ctx.tool ?? 'pointer';
    // Resolve workspace-asset URLs per the render's AssetResolution at the
    // single exit below (token: append ?at= so the cookieless sandbox can load
    // workspace images; share: the token-scoped public path). The CSP in `meta`
    // derives from the SAME value, so URLs and policy can never disagree.
    const finalize = (html) => applyAssetResolution(html, ctx.assets);
    const stepperControl = getStepperControl(bundle);
    const rawEntry = stripNetworkAssetTags(bundle.files[bundle.entrypoint] ?? '');
    const deferred = requestedLibraries(bundle).length > 0 ? deferInlineScripts(rawEntry) : { html: rawEntry, scripts: [] };
    const entry = deferred.html;
    // Safe unescaped: every dynamic CSP part is validated upstream (origins are
    // URL-parsed, share tokens charset-checked) — no quote can reach here.
    const meta = `<meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${artifactCsp(ctx.assets)}">${fontPreloadTags(bundle, ctx.assets)}`;
    const libraries = libraryTags(bundle, ctx.librarySources);
    const runtime = `<script>${escapeScript(iframeRuntimeScript(bundle.artifactId, context, tool, stepperControl, bundle.viewport.mode))}</script>\n${brokenImageScriptTag()}`;
    const styles = [styleTags(bundle), inAppHostStyleTags(bundle, context)].filter(Boolean).join('\n');
    const scripts = scriptTags(bundle, deferred.scripts);
    if (/<html[\s>]/i.test(entry)) {
        let html = addArtifactContextToHtmlTag(entry, context, tool, !!stepperControl);
        html = html.replace(/<head([^>]*)>/i, `<head$1>${meta}\n${libraries}\n${runtime}\n${styles}`);
        if (!/<head[\s>]/i.test(html))
            html = html.replace(/<html([^>]*)>/i, `<html$1><head>${meta}\n${libraries}\n${runtime}\n${styles}</head>`);
        if (/<body[\s>]/i.test(html)) {
            html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${scripts}\n</body>`) : `${html}\n${scripts}`;
        }
        else {
            html += `<body>${scripts}</body>`;
        }
        return finalize(html);
    }
    return finalize(`<!doctype html>
<html data-artifact-context="${escapeAttribute(context)}" data-artifact-tool="${escapeAttribute(tool)}"${stepperControl ? ' data-artifact-stepper="true"' : ''}>
<head>
${meta}
${libraries}
${runtime}
${styles}
</head>
<body>
${entry}
${scripts}
</body>
</html>`);
}
/** Self-contained file (downloaded standalone HTML + headless Playwright). */
function buildStandaloneDocument(bundle, ctx) {
    const options = {
        context: ctx.context,
        baseHref: ctx.baseHref,
        includeRenderHostStyles: ctx.includeRenderHostStyles,
        librarySources: ctx.librarySources,
        assets: ctx.assets,
    };
    // Resolve workspace-asset URLs per the render's AssetResolution at the
    // single exit (token: append ?at= for the cookieless headless render;
    // relative: ./assets paths + placeholders for the standalone export).
    // The CSP in headAdditions derives from the same value.
    const finalize = (html) => applyAssetResolution(html, ctx.assets);
    const stepperControl = getStepperControl(bundle);
    const rawEntry = stripNetworkAssetTags(bundle.files[bundle.entrypoint] ?? '');
    const deferred = requestedLibraries(bundle).length > 0 ? deferInlineScripts(rawEntry) : { html: rawEntry, scripts: [] };
    const entry = deferred.html;
    const styles = headAdditions(bundle, entry, options);
    const libraries = libraryTags(bundle, ctx.librarySources);
    const scripts = scriptTags(bundle, deferred.scripts);
    // Self-fit stepper runtime: ONLY a stepped fullscreen export needs it (a
    // paged reader / plain document letterboxes via CSS alone, and the headless
    // export-context render drives frames itself). Same unified runtime, hostless
    // capability set — the duplicate standalone stepper is gone.
    const runtime = (ctx.context === 'fullscreen' && stepperControl)
        ? `<script data-artifact-standalone-runtime>${escapeScript(standaloneRuntimeScript(stepperControl))}</script>`
        : '';
    // Broken-image runtime first so its capture listener is live before any
    // user/library script runs; it also sweeps already-broken images on load.
    const bodyScripts = [brokenImageScriptTag(), runtime, scripts].filter(Boolean).join('\n');
    const htmlAttrs = ctx.context
        ? {
            'data-artifact-context': ctx.context,
            'data-artifact-stepper': stepperControl ? 'true' : undefined,
        }
        : {};
    if (/<html[\s>]/i.test(entry)) {
        let html = addAttributesToHtmlTag(entry, htmlAttrs);
        if (/<head[\s>]/i.test(html)) {
            html = html.replace(/<head([^>]*)>/i, `<head$1>\n${styles}\n${libraries}\n`);
        }
        else {
            html = html.replace(/<html([^>]*)>/i, `<html$1>\n<head>\n${styles}\n${libraries}\n</head>`);
        }
        if (bodyScripts) {
            if (/<\/body>/i.test(html)) {
                html = html.replace(/<\/body>/i, `${bodyScripts}\n</body>`);
            }
            else {
                html = `${html}\n${bodyScripts}`;
            }
        }
        return finalize(withDoctype(html));
    }
    return finalize(`<!doctype html>
<html${ctx.context ? ` data-artifact-context="${escapeAttribute(ctx.context)}"${stepperControl ? ' data-artifact-stepper="true"' : ''}` : ''}>
<head>
${styles}
${libraries}
</head>
<body>
${entry}
${bodyScripts}
</body>
</html>`);
}
/**
 * Self-contained HTML export + headless Playwright render. A thin adapter over
 * {@link buildArtifactDocument} with `host: 'none'` — kept for its existing
 * callers (the export route, MCP `export_artifact`, the render service). A
 * bare call (no `context`) yields a plain self-contained file, unchanged.
 */
export function buildArtifactHtmlExport(bundle, options = {}) {
    return buildArtifactDocument(bundle, {
        host: 'none',
        context: options.context,
        baseHref: options.baseHref,
        includeRenderHostStyles: options.includeRenderHostStyles,
        librarySources: options.librarySources,
        assets: options.assets,
    });
}
//# sourceMappingURL=artifact-export.js.map