/** * Wrap an artifact's HTML for a sandboxed iframe. Corresponds to * buildSrcdoc in packages/runtime/src/index.ts — the reference version also * injects an edit-mode overlay and tweak bridge, which this starter omits. * * If the model returned a full document, pass it through unchanged; otherwise * wrap the fragment in a minimal doctype shell. * * When `options.deck` is set we also inject a `postMessage` listener that * lets the host advance / rewind slides without relying on the iframe * having keyboard focus. The host posts: * { type: 'od:slide', action: 'next' | 'prev' | 'first' | 'last' | 'go', index?: number } * and the iframe responds with: * { type: 'od:slide-state', active: number, count: number } * after every navigation so the host can render its own counter / dots. */ import { buildManualEditBridge, buildManualEditBridgeStyle, MANUAL_EDIT_DISCOVERY_SELECTOR, MANUAL_EDIT_SOURCE_PATH_ATTR, } from '../edit-mode/bridge'; export type SrcdocOptions = { deck?: boolean; baseHref?: string; initialSlideIndex?: number; commentBridge?: boolean; inspectBridge?: boolean; selectionBridge?: boolean; editBridge?: boolean; paletteBridge?: boolean; initialPalette?: string | null; previewFocusGuard?: boolean; }; export function buildSrcdoc( html: string, options: SrcdocOptions = {} ): string { const head = html.trimStart().slice(0, 64).toLowerCase(); const isFullDoc = head.startsWith("
${html} `; const withOdIds = annotateMissingOdIds(wrapped); const withSourcePaths = options.editBridge ? annotateManualEditSourcePaths(withOdIds) : withOdIds; const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths; const withShim = injectSandboxShim(withBase); const withFocusGuard = options.previewFocusGuard ? injectPreviewFocusGuard(withShim) : withShim; const withDeck = options.deck ? injectDeckBridge(withFocusGuard, options.initialSlideIndex) : withFocusGuard; // Comment + Inspect share an element-selection bridge: both pick a // [data-od-id] / [data-screen-label] node and route the host's reply // to either the comment popover (annotate) or the inspect panel // (live-style overrides). Inject once when either mode is on. Pass the // requested modes through so the bridge boots with picking already // active — without that initial seed there is a window after each // srcdoc rebuild where the host's `od:*-mode` postMessage races the // bridge's own listener install and the iframe ignores clicks. const withSelection = options.selectionBridge || options.commentBridge || options.inspectBridge ? injectSelectionBridge(withDeck, { initialCommentMode: !!options.commentBridge, initialInspectMode: !!options.inspectBridge, }) : withDeck; const withPalette = options.paletteBridge ? injectPaletteBridge(withSelection, { initialPalette: options.initialPalette ?? null }) : withSelection; const withEdit = options.editBridge ? injectManualEditBridge(withPalette) : withPalette; // The tweaks bridge is always injected — it's a passive listener that // toggles a `.tw-panel`'s visibility in response to host postMessage. Tying // it to a per-call option would force iframe srcdoc regeneration (and a // visible flash) every time the host toggle flips. const withTweaks = injectTweaksBridge(withEdit); return injectSrcdocTransportActivationBridge(injectSnapshotBridge(withTweaks)); } /** * Build the lazy transport shell. * * The shell does two things: * 1. Register a listener for `od:srcdoc-transport-activate` that replaces * its own document with the real artifact HTML. * 2. Post `od:srcdoc-transport-ready` to the parent as soon as the listener * is installed. This `ready` signal is the only reliable way for the * host to know the listener is live; without it, the host risks posting * `activate` before the iframe's script has executed (e.g. right after a * key-driven re-mount), in which case the message is dropped and the * iframe stays stuck on the empty shell. See #2253. */ export function buildLazySrcdocTransport(): string { return ` `; } export interface SrcDocActivationInputs { /** The real artifact HTML the host wants to inject into the shell. */ srcDoc: string; /** Host is currently showing the URL-loaded iframe (srcDoc iframe is hidden). */ useUrlLoadPreview: boolean; /** Host's render pipeline is routing through the lazy transport shell. */ useLazySrcDocTransport: boolean; /** The shell document has loaded AND posted `od:srcdoc-transport-ready`. */ shellReady: boolean; /** Which artifact HTML has already been pushed into this shell (dedupe). */ activatedHtml: string | null; } /** * Pure decision for whether the host should now post * `od:srcdoc-transport-activate` to the shell iframe. * * Gating on `shellReady` is the fix for #2253: without it, an activation * triggered by `useUrlLoadPreview` flipping to false (e.g. opening the * Tweaks palette) can fire while the iframe's shell script has not yet * registered its message listener. The message is dropped, the shell stays * on its empty 536-byte body, and the dedupe check then suppresses the * follow-up activation from the iframe's onLoad path. */ export function canActivateSrcDocTransport(state: SrcDocActivationInputs): boolean { if (!state.srcDoc) return false; if (state.useUrlLoadPreview) return false; if (!state.useLazySrcDocTransport) return false; if (!state.shellReady) return false; if (state.activatedHtml === state.srcDoc) return false; return true; } function injectSrcdocTransportActivationBridge(doc: string): string { const script = ``; return injectBeforeBodyEnd(doc, script); } function injectSnapshotBridge(doc: string): string { const script = ``; return injectBeforeBodyEnd(doc, script); } // Palette bridge: re-skin the page on host postMessage. Generated pages // hard-code multiple shades of one accent and a CSS-variable swap will // not catch them. We walk the DOM and shift any chromatic paint to the // target palette's hue while keeping each color's saturation and // lightness — pale tints stay pale, bold CTAs stay bold, just in the // new color family. Mono-noir desaturates instead of shifting. function injectPaletteBridge( doc: string, options: { initialPalette: string | null } = { initialPalette: null }, ): string { const initial = options.initialPalette ? JSON.stringify(String(options.initialPalette)) : 'null'; const script = ``; return injectBeforeBodyEnd(doc, script); } function annotateManualEditSourcePaths(doc: string): string { if (typeof DOMParser === 'undefined') return doc; try { const parsed = new DOMParser().parseFromString(doc, 'text/html'); parsed.body.querySelectorAll(MANUAL_EDIT_DISCOVERY_SELECTOR).forEach((el) => { if (el.hasAttribute(MANUAL_EDIT_SOURCE_PATH_ATTR)) return; const path = sourcePathForElement(el); if (path) el.setAttribute(MANUAL_EDIT_SOURCE_PATH_ATTR, path); }); return serializeHtmlDocument(parsed); } catch { return doc; } } function sourcePathForElement(el: Element): string { const parts: number[] = []; let node: Element | null = el; while (node && node !== node.ownerDocument.body) { const parent: Element | null = node.parentElement; if (!parent) break; parts.unshift(Array.prototype.indexOf.call(parent.children, node)); node = parent; } return parts.length ? `path-${parts.join('-')}` : ''; } function serializeHtmlDocument(doc: Document): string { const doctype = doc.doctype ? '\n' : ''; return `${doctype}${doc.documentElement.outerHTML}`; } /** * Auto-annotate structural HTML elements that lack `data-od-id` or * `data-screen-label` so that the selection bridge (Picker / Pods / * Tweaks) can target them. This fixes imported designs whose HTML was * generated outside of Open Design and therefore carries no OD-specific * annotations. */ function annotateMissingOdIds(doc: string): string { if (typeof DOMParser === 'undefined') return doc; try { const parsed = new DOMParser().parseFromString(doc, 'text/html'); // Only target divs that are direct children of semantic containers or body; // deeply nested layout divs (e.g. flex/grid wrappers) create noise in the // selection bridge without adding meaningful pickable targets. const selector = [ 'section', 'article', 'header', 'footer', 'nav', 'main', 'aside', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'button', 'a', '[id]', 'body > div[class]', 'body > div[id]', 'section > div[class]', 'section > div[id]', 'article > div[class]', 'article > div[id]', 'main > div[class]', 'main > div[id]', 'header > div[class]', 'header > div[id]', 'footer > div[class]', 'footer > div[id]', 'nav > div[class]', 'nav > div[id]', 'aside > div[class]', 'aside > div[id]', '[id] > div[class]', '[id] > div[id]', ].join(', '); const skipTags = new Set(['script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed']); let fallbackIndex = 0; parsed.body.querySelectorAll(selector).forEach((el) => { if (el.hasAttribute('data-od-id') || el.hasAttribute('data-screen-label')) return; const tag = el.tagName.toLowerCase(); if (skipTags.has(tag)) return; const path = sourcePathForElement(el); el.setAttribute('data-od-id', path || `od-${tag}-${fallbackIndex++}`); }); return serializeHtmlDocument(parsed); } catch { return doc; } } function injectManualEditBridge(doc: string): string { const withStyle = injectBeforeHeadEnd(doc, buildManualEditBridgeStyle()); return injectBeforeBodyEnd(withStyle, buildManualEditBridge(true)); } function injectBeforeHeadEnd(doc: string, payload: string): string { if (typeof DOMParser !== 'undefined') { try { const parsed = new DOMParser().parseFromString(doc, 'text/html'); if (parsed.head) parsed.head.insertAdjacentHTML('beforeend', payload); return serializeHtmlDocument(parsed); } catch { /* DOMParser failed; fall through to string path */ } } // String fallback: find the real (last one before ) // to skip literals inside `; if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (m) => `${m}${shim}`); if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (m) => `${m}${shim}`); return shim + doc; } function injectPreviewFocusGuard(doc: string): string { const script = ``; if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (m) => `${m}${script}`); if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (m) => `${m}${script}`); return script + doc; } // Selection bridge: shared substrate for Comment mode and Inspect mode. // Both modes pick a [data-od-id] / [data-screen-label] element on click; // the difference is what the host does with the selection — annotate // (Comment) or live-tune basic styles (Inspect). // // Inspect adds four messages on top of the comment protocol: // in: { type: 'od:inspect-set', elementId, selector, prop, value } // Apply (or unset, when value === '') a per-element CSS override. // in: { type: 'od:inspect-reset', elementId? } Clear overrides for one // element, or all if elementId is omitted. // in: { type: 'od:inspect-extract' } Reply with the cumulative // override map so the host can persist to source. // in: { type: 'od:inspect-replay', overrides } Replace the in-memory // override map with the host's authoritative set so the iframe // preview matches host state after every srcdoc rebuild. Without // this the bridge re-hydrates only the persisted , raw HTML) // through od:inspect-set. Keep this in sync with the InspectPanel UI. var ALLOWED_PROPS = { 'color': true, 'background-color': true, 'font-size': true, 'font-weight': true, 'font-family': true, 'line-height': true, 'text-align': true, 'padding': true, 'padding-top': true, 'padding-right': true, 'padding-bottom': true, 'padding-left': true, 'border-radius': true }; // Reject any value that could break out of a 'prop: value' declaration: // semicolons (extra declarations), braces (close the rule), angle // brackets (close the `; const style = ``; return injectBeforeBodyEnd(injectBeforeHeadEnd(doc, style), script); } // The deck bridge supports three deck conventions found across our skills // and freeform-generated artifacts: // 1. Horizontal scroll decks (simple-deck, guizang-ppt) — slides laid out // side-by-side, navigation = scrollTo({ left }). // 2. Class-toggle decks (deck-framework, freeform pitches) — one slide // carries `.active` or `.is-active`; siblings are display:none. Their // own JS listens for ArrowRight/Left, so we drive them by dispatching // synthetic KeyboardEvents. // 3. Visibility-only decks — no class toggle, slides hidden via inline // style. We fall back to keyboard dispatch + visibility detection. // // All three report `{ active, count }` back to the host so the toolbar can // render a unified counter. A MutationObserver on each `.slide` lets us // catch class changes from the deck's own keyboard handler. // // We also inject a small CSS override that fixes a common authoring // mistake in fixed-canvas decks: a `.stage { display: grid; place-items: // center }` only centers items within their grid cells, but the track // itself stays `start`-aligned, so the 1920x1080 canvas top-lefts at // (0,0) of the stage. Combined with `transform-origin: center center`, // the scaled canvas ends up offset toward the bottom-right of any // preview that's smaller than 1920x1080 — exactly what users see in the // sandbox iframe. `place-content: center` centers the track itself. // // Framework decks (apps/daemon/src/prompts/deck-framework.ts) opt out: // their `fit()` already centers a `transform-origin: top left` stage with // an explicit `translate(tx, ty)` that assumes the stage's natural layout // position is (0, 0). If we force `place-content: center` on their // `.deck-shell` grid, the implicit track gets re-centered to // ((sw-1920)/2, (sh-1080)/2) and `fit()`'s translate stacks on top, so // the scaled stage lands ~1000px off-screen and the user sees a mostly- // black preview with a sliver of slide content in the top-left. Skip the // override whenever the framework's marker id is present. function injectDeckBridge(doc: string, initialSlideIndex = 0): string { const safeInitialSlideIndex = Number.isFinite(initialSlideIndex) ? Math.max(0, Math.floor(initialSlideIndex)) : 0; const isFrameworkDeck = /\bid\s*=\s*["']deck-stage["']/i.test(doc); const styleFix = isFrameworkDeck ? '' : ``; const script = ``; return injectBeforeBodyEnd(injectBeforeHeadEnd(doc, styleFix), script); } // The tweaks bridge lets the host toolbar toggle the visibility of the artifact's // native tweaks panel. Bidirectional: host posts `od:tweaks-panel-visible` to // drive panel visibility; bridge posts `od:tweaks-panel-state` back whenever the // artifact's own `× close` button or `T` shortcut flips the `.tw-hidden` class, // so the toolbar toggle stays in sync. Also reports `od:tweaks-available` so the // host can disable the toggle on artifacts without a `.tw-panel`. function injectTweaksBridge(doc: string): string { // Hide-state styling mirrors the artifact's own `.tw-hidden` (transform + // opacity) so the CSS transition plays in both directions. `.tw-restore` is // kept permanently hidden — the host toolbar is the only entry point. const style = ``; const script = ``; const withStyle = /<\/head>/i.test(doc) ? doc.replace(/<\/head>/i, style + '') : /]*>/i.test(doc) ? doc.replace(/]*>/i, (m) => m + style) : style + doc; // Inject the bridge as early as possible (inside ) so the synchronous // attribute set runs before the artifact body parses. if (/<\/head>/i.test(withStyle)) return withStyle.replace(/<\/head>/i, script + ''); if (/]*>/i.test(withStyle)) return withStyle.replace(/]*>/i, (m) => m + script); return script + withStyle; }