diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs
index f8846b851..0f44a9fa4 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs
@@ -89,6 +89,410 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024;
const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100;
const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024;
+const PREVIEW_FIT_BRIDGE_PATH: &str = "/__genarrative/local-preview-fit.js";
+const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
+ const messageType = 'genarrative.local-preview-size.v1';
+ const maxContentProbes = 512;
+ let ready = document.readyState === 'complete';
+ let frame = 0;
+ let lastPublishedSize = '';
+ const elementLayoutSamples = { width: new WeakMap(), height: new WeakMap() };
+ const previousRootLayoutSample = {};
+ const previousBodyLayoutSample = {};
+ const rootIndependentLayout = { width: false, height: false };
+ const contentResolutionState = { width: { axis: 'width' }, height: { axis: 'height' } };
+ const resetLayoutCouplingSamples = (axis) => {
+ elementLayoutSamples[axis] = new WeakMap();
+ previousRootLayoutSample[axis] = undefined;
+ previousBodyLayoutSample[axis] = undefined;
+ };
+ const resolveViewportCoupling = (
+ extent, viewport, previousExtent, previousViewport, previousCoupled,
+ ) => {
+ if (extent === undefined || previousExtent === undefined || previousViewport === undefined) {
+ return false;
+ }
+ const viewportDelta = viewport - previousViewport;
+ if (Math.abs(viewportDelta) < 1) {
+ return Math.abs(extent - previousExtent) < 1 ? previousCoupled : false;
+ }
+ const extentDelta = extent - previousExtent;
+ return Math.abs(extentDelta) >= 1 && extentDelta * viewportDelta > 0;
+ };
+ const previewStyleExtent = (value) => {
+ const extent = Number.parseFloat(value || '');
+ return Number.isFinite(extent) ? extent : undefined;
+ };
+ const previewTypedStyleValue = (element, property) => {
+ try {
+ return element?.computedStyleMap?.().get(property)?.toString().trim().toLowerCase();
+ } catch {
+ return undefined;
+ }
+ };
+ const previewTypedMinimumCreatesExtent = (value) => {
+ if (value === undefined || value === 'auto' || value === 'none') return false;
+ const extent = Number.parseFloat(value);
+ return Number.isFinite(extent) ? extent > 0 : true;
+ };
+ const rootAxisMirrorsBody = (axis, root, body, rootExtent, bodyExtent) => {
+ if (!root || !body) return false;
+ const sameExtent = Math.abs(rootExtent - bodyExtent) <= 1;
+ const sizeProperty = axis === 'width' ? 'width' : 'height';
+ const minimumProperty = axis === 'width' ? 'min-width' : 'min-height';
+ const typedSize = previewTypedStyleValue(root, sizeProperty);
+ const typedMinimum = previewTypedStyleValue(root, minimumProperty);
+ if (typedSize !== undefined || typedMinimum !== undefined) {
+ rootIndependentLayout[axis] = (typedSize !== undefined && typedSize !== 'auto')
+ || previewTypedMinimumCreatesExtent(typedMinimum);
+ } else if (!sameExtent) {
+ rootIndependentLayout[axis] = true;
+ }
+ return sameExtent && !rootIndependentLayout[axis];
+ };
+ const previewStyleUsesViewport = (value) => (
+ (value || '').includes('%')
+ || /\b(?:v[wh]|vmin|vmax|sv[wh]|lv[wh]|dv[wh]|vi|vb|svi|svb|lvi|lvb|dvi|dvb)\b/i.test(value || '')
+ );
+ const previewViewportStyleBaseline = (value, viewport) => {
+ const normalized = (value || '').trim().toLowerCase();
+ const amount = Number.parseFloat(normalized);
+ if (!Number.isFinite(amount) || normalized.includes('calc(')) return undefined;
+ if (normalized.endsWith('%')) return viewport * amount / 100;
+ if (/^-?\d*\.?\d+(?:d?v[wh]|s?v[wh]|l?v[wh])$/.test(normalized)) {
+ return viewport * amount / 100;
+ }
+ return undefined;
+ };
+ const sampleAxisViewportCoupling = (
+ edge, sizeStyle, minimumStyle, viewport, previous,
+ ) => {
+ const size = previewStyleExtent(sizeStyle);
+ const minimum = previewStyleExtent(minimumStyle);
+ const sizeUsesViewport = previewStyleUsesViewport(sizeStyle);
+ const minimumUsesViewport = previewStyleUsesViewport(minimumStyle);
+ const edgeCoupled = resolveViewportCoupling(
+ edge, viewport, previous?.edge, previous?.viewport, previous?.edgeCoupled || false,
+ );
+ const sizeCoupled = sizeUsesViewport || resolveViewportCoupling(
+ size, viewport, previous?.size, previous?.viewport,
+ previous?.sizeUsesViewport && !sizeUsesViewport ? false : previous?.sizeCoupled || false,
+ );
+ const minimumCoupled = minimumUsesViewport || resolveViewportCoupling(
+ minimum, viewport, previous?.minimum, previous?.viewport,
+ previous?.minimumUsesViewport && !minimumUsesViewport
+ ? false
+ : previous?.minimumCoupled || false,
+ );
+ const edgeBaseline = edgeCoupled
+ ? previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge
+ : undefined;
+ const sizeBaseline = sizeCoupled
+ ? previous?.sizeBaseline
+ ?? previous?.baseline
+ ?? previous?.size
+ ?? previewViewportStyleBaseline(sizeStyle, viewport)
+ ?? size
+ : undefined;
+ const minimumBaseline = minimumCoupled
+ ? previous?.minimumBaseline
+ ?? previous?.baseline
+ ?? previous?.minimum
+ ?? previewViewportStyleBaseline(minimumStyle, viewport)
+ ?? minimum
+ : undefined;
+ const baseline = Math.max(
+ 0, edgeBaseline || 0, sizeBaseline || 0, minimumBaseline || 0,
+ ) || undefined;
+ return {
+ edge,
+ size,
+ minimum,
+ viewport,
+ edgeCoupled,
+ sizeCoupled,
+ minimumCoupled,
+ edgeBaseline,
+ sizeBaseline,
+ minimumBaseline,
+ sizeUsesViewport,
+ minimumUsesViewport,
+ baseline,
+ coupled: edgeCoupled || sizeCoupled || minimumCoupled,
+ };
+ };
+ const sampleElementViewportCoupling = (
+ element, widthExtent, heightExtent, viewportWidth, viewportHeight,
+ ) => {
+ const style = window.getComputedStyle(element);
+ const width = sampleAxisViewportCoupling(
+ widthExtent, style.width, style.minWidth, viewportWidth,
+ elementLayoutSamples.width.get(element),
+ );
+ const height = sampleAxisViewportCoupling(
+ heightExtent, style.height, style.minHeight, viewportHeight,
+ elementLayoutSamples.height.get(element),
+ );
+ elementLayoutSamples.width.set(element, width);
+ elementLayoutSamples.height.set(element, height);
+ return {
+ width: width.coupled,
+ height: height.coupled,
+ widthBaseline: width.baseline,
+ heightBaseline: height.baseline,
+ };
+ };
+ const sampleDocumentViewportCoupling = (
+ previousLayoutSample, widthExtent, heightExtent, style, viewportWidth, viewportHeight,
+ ) => {
+ const width = sampleAxisViewportCoupling(
+ widthExtent, style?.width, style?.minWidth, viewportWidth,
+ previousLayoutSample.width,
+ );
+ const height = sampleAxisViewportCoupling(
+ heightExtent, style?.height, style?.minHeight, viewportHeight,
+ previousLayoutSample.height,
+ );
+ previousLayoutSample.width = width;
+ previousLayoutSample.height = height;
+ return {
+ width: width.coupled,
+ height: height.coupled,
+ widthBaseline: width.baseline,
+ heightBaseline: height.baseline,
+ };
+ };
+ const measureContentBounds = (body, viewportWidth, viewportHeight) => {
+ if (!body) return { width: 0, height: 0, truncated: false };
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
+ let width = 0;
+ let height = 0;
+ let widthBaseline = 0;
+ let heightBaseline = 0;
+ let visited = 0;
+ let element = walker.nextNode();
+ while (element && visited < maxContentProbes) {
+ visited += 1;
+ const rect = element.getBoundingClientRect();
+ if (rect.width > 0 || rect.height > 0) {
+ const widthExtent = rect.right + window.scrollX;
+ const heightExtent = rect.bottom + window.scrollY;
+ const coupling = sampleElementViewportCoupling(
+ element, widthExtent, heightExtent, viewportWidth, viewportHeight,
+ );
+ if (!coupling.width) width = Math.max(width, widthExtent);
+ if (!coupling.height) height = Math.max(height, heightExtent);
+ widthBaseline = Math.max(widthBaseline, coupling.widthBaseline || 0);
+ heightBaseline = Math.max(heightBaseline, coupling.heightBaseline || 0);
+ }
+ element = walker.nextNode();
+ }
+ return {
+ width: Math.ceil(Math.max(0, width)),
+ height: Math.ceil(Math.max(0, height)),
+ widthBaseline: Math.ceil(Math.max(0, widthBaseline)),
+ heightBaseline: Math.ceil(Math.max(0, heightBaseline)),
+ truncated: element !== null,
+ };
+ };
+ const resolveIntrinsicExtent = (bodyExtent, bodyCoupled, contentExtent, truncated) => (
+ truncated || !bodyCoupled
+ ? Math.max(bodyExtent, contentExtent)
+ : contentExtent
+ );
+ const resolveContentExtent = (
+ state, viewportExtent, scrollExtent, intrinsicExtent, probedExtent, truncated,
+ coupledBaseline,
+ ) => {
+ const viewportChanged = state.viewport !== undefined
+ && Math.abs(viewportExtent - state.viewport) >= 1;
+ const hostAppliedPrevious = state.resolved !== undefined
+ && Math.abs(viewportExtent - state.resolved) <= 1;
+ const nativeReset = viewportChanged && !hostAppliedPrevious;
+ if (nativeReset) {
+ state.responsive = false;
+ state.responsiveFloor = undefined;
+ state.responsiveCandidateCount = 0;
+ state.responsiveCandidateFloor = undefined;
+ state.responsiveCandidateDirection = 0;
+ resetLayoutCouplingSamples(state.axis);
+ }
+ const viewportDelta = state.viewport === undefined ? 0 : viewportExtent - state.viewport;
+ const scrollDelta = state.scroll === undefined ? 0 : scrollExtent - state.scroll;
+ const responsiveScroll = !nativeReset
+ && hostAppliedPrevious
+ && Math.abs(viewportDelta) >= 1
+ && Math.abs(scrollDelta) >= 1
+ && viewportDelta * scrollDelta > 0;
+ const candidateContradicted = !state.responsive
+ && (state.responsiveCandidateCount || 0) > 0
+ && (
+ (viewportChanged && hostAppliedPrevious && !responsiveScroll)
+ || (
+ Math.abs(scrollDelta) >= 1
+ && (state.responsiveCandidateDirection || 0) * scrollDelta < 0
+ )
+ );
+ if (responsiveScroll && !state.responsive) {
+ const candidateFloor = truncated
+ ? Math.max(coupledBaseline || 0, state.resolved || 1)
+ : coupledBaseline || state.resolved;
+ if (truncated) {
+ state.responsive = true;
+ state.responsiveFloor = candidateFloor;
+ state.responsiveCandidateCount = 0;
+ state.responsiveCandidateFloor = undefined;
+ state.responsiveCandidateDirection = 0;
+ } else {
+ if ((state.responsiveCandidateCount || 0) === 0) {
+ state.responsiveCandidateDirection = Math.sign(viewportDelta);
+ }
+ state.responsiveCandidateCount = (state.responsiveCandidateCount || 0) + 1;
+ state.responsiveCandidateFloor = state.responsiveCandidateFloor || candidateFloor;
+ if (state.responsiveCandidateCount >= 2) {
+ state.responsive = true;
+ state.responsiveFloor = state.responsiveCandidateFloor;
+ state.responsiveCandidateCount = 0;
+ state.responsiveCandidateFloor = undefined;
+ state.responsiveCandidateDirection = 0;
+ }
+ }
+ } else if (candidateContradicted) {
+ state.responsiveCandidateCount = 0;
+ state.responsiveCandidateFloor = undefined;
+ state.responsiveCandidateDirection = 0;
+ }
+ const resolved = state.responsive
+ ? Math.max(state.responsiveFloor || 1, truncated ? probedExtent : intrinsicExtent)
+ : (scrollExtent > viewportExtent ? scrollExtent : intrinsicExtent);
+ if (state.responsive && truncated) {
+ state.responsiveFloor = Math.max(state.responsiveFloor || 1, resolved);
+ }
+ state.viewport = viewportExtent;
+ state.scroll = scrollExtent;
+ state.resolved = resolved;
+ return resolved;
+ };
+ const publish = () => {
+ frame = 0;
+ if (!ready || window.parent === window) return;
+ const root = document.documentElement;
+ const body = document.body;
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ const rootRect = root?.getBoundingClientRect();
+ const rootStyle = root ? window.getComputedStyle(root) : null;
+ const bodyRect = body?.getBoundingClientRect();
+ const bodyStyle = body ? window.getComputedStyle(body) : null;
+ const horizontalMargin = (Number.parseFloat(bodyStyle?.marginLeft || '0') || 0)
+ + (Number.parseFloat(bodyStyle?.marginRight || '0') || 0);
+ const verticalMargin = (Number.parseFloat(bodyStyle?.marginTop || '0') || 0)
+ + (Number.parseFloat(bodyStyle?.marginBottom || '0') || 0);
+ const intrinsicBodyWidth = Math.ceil(Math.max(body?.offsetWidth || 0, bodyRect?.width || 0) + horizontalMargin);
+ const intrinsicBodyHeight = Math.ceil(Math.max(body?.offsetHeight || 0, bodyRect?.height || 0) + verticalMargin);
+ const intrinsicRootWidth = Math.ceil(Math.max(
+ root?.offsetWidth || 0, rootRect?.width || 0, (rootRect?.right || 0) + window.scrollX,
+ ));
+ const intrinsicRootHeight = Math.ceil(Math.max(
+ root?.offsetHeight || 0, rootRect?.height || 0, (rootRect?.bottom || 0) + window.scrollY,
+ ));
+ const rootWidthMirrorsBody = rootAxisMirrorsBody(
+ 'width', root, body, intrinsicRootWidth, intrinsicBodyWidth,
+ );
+ const rootHeightMirrorsBody = rootAxisMirrorsBody(
+ 'height', root, body, intrinsicRootHeight, intrinsicBodyHeight,
+ );
+ const rootCoupling = sampleDocumentViewportCoupling(
+ previousRootLayoutSample,
+ intrinsicRootWidth, intrinsicRootHeight, rootStyle, viewportWidth, viewportHeight,
+ );
+ const bodyCoupling = sampleDocumentViewportCoupling(
+ previousBodyLayoutSample,
+ intrinsicBodyWidth, intrinsicBodyHeight, bodyStyle, viewportWidth, viewportHeight,
+ );
+ const contentBounds = measureContentBounds(body, viewportWidth, viewportHeight);
+ const coupledWidthBaseline = Math.max(
+ rootWidthMirrorsBody ? 0 : rootCoupling.widthBaseline || 0,
+ bodyCoupling.widthBaseline || 0,
+ contentBounds.widthBaseline || 0,
+ );
+ const coupledHeightBaseline = Math.max(
+ rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0,
+ bodyCoupling.heightBaseline || 0,
+ contentBounds.heightBaseline || 0,
+ );
+ const intrinsicBodyContentWidth = resolveIntrinsicExtent(
+ intrinsicBodyWidth, bodyCoupling.width,
+ contentBounds.width, contentBounds.truncated,
+ );
+ const intrinsicBodyContentHeight = resolveIntrinsicExtent(
+ intrinsicBodyHeight, bodyCoupling.height,
+ contentBounds.height, contentBounds.truncated,
+ );
+ const intrinsicContentWidth = Math.max(
+ coupledWidthBaseline,
+ resolveIntrinsicExtent(
+ intrinsicRootWidth, rootCoupling.width || rootWidthMirrorsBody,
+ intrinsicBodyContentWidth, contentBounds.truncated,
+ ),
+ );
+ const intrinsicContentHeight = Math.max(
+ coupledHeightBaseline,
+ resolveIntrinsicExtent(
+ intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody,
+ intrinsicBodyContentHeight, contentBounds.truncated,
+ ),
+ );
+ const scrollWidth = Math.ceil(Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0));
+ const scrollHeight = Math.ceil(Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0));
+ const size = {
+ contentWidth: Math.max(
+ 1, resolveContentExtent(
+ contentResolutionState.width, viewportWidth, scrollWidth, intrinsicContentWidth,
+ contentBounds.width, contentBounds.truncated, coupledWidthBaseline,
+ ),
+ ),
+ contentHeight: Math.max(
+ 1, resolveContentExtent(
+ contentResolutionState.height, viewportHeight, scrollHeight, intrinsicContentHeight,
+ contentBounds.height, contentBounds.truncated, coupledHeightBaseline,
+ ),
+ ),
+ viewportWidth,
+ viewportHeight,
+ };
+ const sizeKey = `${size.contentWidth}:${size.contentHeight}:${size.viewportWidth}:${size.viewportHeight}`;
+ if (sizeKey === lastPublishedSize) return;
+ lastPublishedSize = sizeKey;
+ window.parent.postMessage({ type: messageType, ...size }, '*');
+ };
+ const schedule = () => {
+ if (frame === 0) frame = window.requestAnimationFrame(publish);
+ };
+ const activate = () => {
+ ready = true;
+ const style = document.createElement('style');
+ style.dataset.genarrativePreviewFit = 'true';
+ style.textContent = 'html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}';
+ (document.head || document.documentElement).appendChild(style);
+ schedule();
+ if (typeof ResizeObserver === 'function') {
+ const resizeObserver = new ResizeObserver(schedule);
+ resizeObserver.observe(document.documentElement);
+ if (document.body) resizeObserver.observe(document.body);
+ }
+ document.addEventListener('load', schedule, true);
+ document.fonts?.ready.then(schedule);
+ window.addEventListener('resize', schedule, { passive: true });
+ window.setInterval(() => {
+ if (!document.hidden) schedule();
+ }, 500);
+ };
+ if (ready) activate();
+ else window.addEventListener('load', activate, { once: true });
+})();"#;
+const PREVIEW_FIT_BRIDGE_TAG: &str =
+ r#""#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PreviewListenerAcceptDisposition {
@@ -560,6 +964,20 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
);
}
+ if url_path.split('?').next() == Some(PREVIEW_FIT_BRIDGE_PATH) {
+ let body = if is_head {
+ Vec::new()
+ } else {
+ PREVIEW_FIT_BRIDGE_SCRIPT.as_bytes().to_vec()
+ };
+ return http_response(
+ "200 OK",
+ "text/javascript; charset=utf-8",
+ &body,
+ PREVIEW_FIT_BRIDGE_SCRIPT.len(),
+ );
+ }
+
let file_path = match resolve_preview_path(root, url_path) {
Ok(path) => path,
Err(_) => {
@@ -574,11 +992,419 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
return http_response("404 Not Found", "text/plain", body, b"not found".len());
}
};
+ let body = if content_type(&file_path).starts_with("text/html") {
+ inject_preview_fit_bridge(body)
+ } else {
+ body
+ };
let content_length = body.len();
let body = if is_head { Vec::new() } else { body };
http_response("200 OK", content_type(&file_path), &body, content_length)
}
+fn inject_preview_fit_bridge(body: Vec) -> Vec {
+ let html = match String::from_utf8(body) {
+ Ok(html) => html,
+ Err(error) => return error.into_bytes(),
+ };
+ let scan = scan_preview_fit_bridge_html(&html);
+ if scan.has_bridge_script {
+ return html.into_bytes();
+ }
+ let insertion = scan.body_end.or(scan.html_end).unwrap_or(scan.safe_append);
+ let mut output = String::with_capacity(html.len() + PREVIEW_FIT_BRIDGE_TAG.len());
+ output.push_str(&html[..insertion]);
+ output.push_str(PREVIEW_FIT_BRIDGE_TAG);
+ output.push_str(&html[insertion..]);
+ output.into_bytes()
+}
+
+#[derive(Clone, Copy, Debug)]
+struct PreviewFitBridgeHtmlScan {
+ has_bridge_script: bool,
+ body_end: Option,
+ html_end: Option,
+ safe_append: usize,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct PreviewHtmlTag {
+ start: usize,
+ name_start: usize,
+ name_end: usize,
+ end: usize,
+ closing: bool,
+}
+
+fn scan_preview_fit_bridge_html(html: &str) -> PreviewFitBridgeHtmlScan {
+ let bytes = html.as_bytes();
+ let mut scan = PreviewFitBridgeHtmlScan {
+ has_bridge_script: false,
+ body_end: None,
+ html_end: None,
+ safe_append: bytes.len(),
+ };
+ let mut cursor = 0;
+ let mut template_depth = 0_u32;
+ let mut outer_template_start = None;
+
+ while let Some(relative_start) = bytes[cursor..].iter().position(|byte| *byte == b'<') {
+ let tag_start = cursor + relative_start;
+ if bytes[tag_start..].starts_with(b"") {
+ return Some(cursor + 3);
+ }
+ if bytes.get(cursor..cursor + 4) == Some(b"--!>") {
+ return Some(cursor + 4);
+ }
+ cursor += 1;
+ }
+ None
+}
+
+fn preview_html_doctype_at(bytes: &[u8], start: usize) -> bool {
+ let name_start = start + 2;
+ let name_end = name_start + b"doctype".len();
+ bytes
+ .get(name_start..name_end)
+ .is_some_and(|name| name.eq_ignore_ascii_case(b"doctype"))
+}
+
+fn preview_html_doctype_end(bytes: &[u8], start: usize) -> Option {
+ bytes
+ .get(start..)?
+ .iter()
+ .position(|byte| *byte == b'>')
+ .map(|relative_end| start + relative_end + 1)
+}
+
+fn preview_html_tag_at(bytes: &[u8], start: usize) -> Option {
+ if bytes.get(start) != Some(&b'<') {
+ return None;
+ }
+ let mut cursor = start + 1;
+ let closing = bytes.get(cursor) == Some(&b'/');
+ if closing {
+ cursor += 1;
+ }
+ let name_start = cursor;
+ if !bytes.get(cursor).is_some_and(u8::is_ascii_alphabetic) {
+ return None;
+ }
+ cursor += 1;
+ while bytes
+ .get(cursor)
+ .is_some_and(|byte| !preview_html_space(*byte) && !matches!(*byte, b'/' | b'>'))
+ {
+ cursor += 1;
+ }
+ let name_end = cursor;
+ let end = preview_html_markup_end(bytes, cursor)?;
+ Some(PreviewHtmlTag {
+ start,
+ name_start,
+ name_end,
+ end,
+ closing,
+ })
+}
+
+fn preview_html_markup_end(bytes: &[u8], mut cursor: usize) -> Option {
+ let mut quote = None;
+ while let Some(byte) = bytes.get(cursor).copied() {
+ if let Some(expected) = quote {
+ if byte == expected {
+ quote = None;
+ }
+ } else if matches!(byte, b'\'' | b'"') {
+ quote = Some(byte);
+ } else if byte == b'>' {
+ return Some(cursor + 1);
+ }
+ cursor += 1;
+ }
+ None
+}
+
+fn preview_html_tag_name_is(bytes: &[u8], tag: PreviewHtmlTag, expected: &[u8]) -> bool {
+ bytes[tag.name_start..tag.name_end].eq_ignore_ascii_case(expected)
+}
+
+fn preview_html_raw_text_name(bytes: &[u8], tag: PreviewHtmlTag) -> Option<&'static [u8]> {
+ [
+ b"script".as_slice(),
+ b"style".as_slice(),
+ b"title".as_slice(),
+ b"textarea".as_slice(),
+ b"xmp".as_slice(),
+ b"iframe".as_slice(),
+ b"noembed".as_slice(),
+ b"noframes".as_slice(),
+ b"noscript".as_slice(),
+ ]
+ .into_iter()
+ .find(|name| preview_html_tag_name_is(bytes, tag, name))
+}
+
+fn preview_html_raw_text_close(
+ bytes: &[u8],
+ mut cursor: usize,
+ name: &[u8],
+) -> Option<(usize, usize)> {
+ while cursor < bytes.len() {
+ let relative_start = bytes[cursor..].iter().position(|byte| *byte == b'<')?;
+ let close_start = cursor + relative_start;
+ let name_start = close_start + 2;
+ let name_end = name_start.checked_add(name.len())?;
+ if bytes.get(close_start + 1) == Some(&b'/')
+ && bytes
+ .get(name_start..name_end)
+ .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
+ && bytes
+ .get(name_end)
+ .is_some_and(|byte| preview_html_space(*byte) || matches!(*byte, b'/' | b'>'))
+ {
+ let end = preview_html_markup_end(bytes, name_end)?;
+ return Some((close_start, end));
+ }
+ cursor = close_start + 1;
+ }
+ None
+}
+
+fn preview_html_tag_has_bridge_src(bytes: &[u8], tag: PreviewHtmlTag) -> bool {
+ let mut cursor = tag.name_end;
+ let limit = tag.end.saturating_sub(1);
+ let mut first_src = None;
+ let mut has_type = false;
+ let mut has_nomodule = false;
+ while cursor < limit {
+ while cursor < limit && preview_html_space(bytes[cursor]) {
+ cursor += 1;
+ }
+ if cursor >= limit || bytes[cursor] == b'/' {
+ break;
+ }
+ let attribute_start = cursor;
+ while cursor < limit
+ && !preview_html_space(bytes[cursor])
+ && !matches!(bytes[cursor], b'=' | b'/' | b'>')
+ {
+ cursor += 1;
+ }
+ if cursor == attribute_start {
+ cursor += 1;
+ continue;
+ }
+ let attribute_end = cursor;
+ let is_src = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"src");
+ let is_type = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"type");
+ let is_nomodule = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"nomodule");
+ if is_type {
+ has_type = true;
+ }
+ if is_nomodule {
+ has_nomodule = true;
+ }
+ while cursor < limit && preview_html_space(bytes[cursor]) {
+ cursor += 1;
+ }
+ if cursor >= limit || bytes[cursor] != b'=' {
+ if is_src && first_src.is_none() {
+ first_src = Some(false);
+ }
+ continue;
+ }
+ cursor += 1;
+ while cursor < limit && preview_html_space(bytes[cursor]) {
+ cursor += 1;
+ }
+ let (value_start, value_end) = if cursor < limit && matches!(bytes[cursor], b'\'' | b'"') {
+ let quote = bytes[cursor];
+ cursor += 1;
+ let value_start = cursor;
+ while cursor < limit && bytes[cursor] != quote {
+ cursor += 1;
+ }
+ let value_end = cursor;
+ if cursor < limit {
+ cursor += 1;
+ }
+ (value_start, value_end)
+ } else {
+ let value_start = cursor;
+ while cursor < limit && !preview_html_space(bytes[cursor]) && bytes[cursor] != b'>' {
+ cursor += 1;
+ }
+ (value_start, cursor)
+ };
+ if is_src && first_src.is_none() {
+ first_src = Some(bytes[value_start..value_end] == *PREVIEW_FIT_BRIDGE_PATH.as_bytes());
+ }
+ }
+ first_src == Some(true) && !has_type && !has_nomodule
+}
+
+fn preview_html_space(byte: u8) -> bool {
+ matches!(byte, b'\t' | b'\n' | 0x0c | b'\r' | b' ')
+}
+
+fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option {
+ if needle.is_empty() {
+ return Some(0);
+ }
+ haystack
+ .windows(needle.len())
+ .position(|candidate| candidate == needle)
+}
+
pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result {
let path = url_path.split('?').next().unwrap_or("/");
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
index 1167a8440..ad043b690 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
@@ -3440,6 +3440,28 @@ fn local_preview_server_serves_game_index() {
assert!(response.contains("200 OK"), "{response}");
assert!(response.contains("还没有生成游戏"));
+ assert!(
+ response.contains("/__genarrative/local-preview-fit.js"),
+ "{response}"
+ );
+
+ let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
+ stream
+ .write_all(b"GET /__genarrative/local-preview-fit.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
+ .expect("fit bridge request");
+ let mut response = String::new();
+ stream
+ .read_to_string(&mut response)
+ .expect("fit bridge response");
+ assert!(response.contains("200 OK"), "{response}");
+ assert!(
+ response.contains("Content-Type: text/javascript; charset=utf-8"),
+ "{response}"
+ );
+ assert!(
+ response.contains("genarrative.local-preview-size.v1"),
+ "{response}"
+ );
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
stream
@@ -3458,6 +3480,929 @@ fn local_preview_server_serves_game_index() {
fs::remove_dir_all(root).ok();
}
+fn local_preview_html_response_body(root: &Path, html: &str) -> String {
+ fs::create_dir_all(root.join("game")).expect("game dir");
+ fs::write(root.join("game/index.html"), html).expect("write preview html");
+ let response = build_preview_response(root, "GET", "/");
+ let header_end = response
+ .windows(b"\r\n\r\n".len())
+ .position(|window| window == b"\r\n\r\n")
+ .expect("complete preview response headers")
+ + b"\r\n\r\n".len();
+ String::from_utf8(response[header_end..].to_vec()).expect("UTF-8 preview HTML")
+}
+
+#[test]
+fn local_preview_fit_bridge_only_publishes_changed_layout_measurements() {
+ let root = unique_project_path();
+ let response = String::from_utf8(build_preview_response(
+ &root,
+ "GET",
+ "/__genarrative/local-preview-fit.js",
+ ))
+ .expect("fit bridge response");
+
+ assert!(
+ response.contains("let lastPublishedSize = ''"),
+ "{response}"
+ );
+ assert!(
+ response.contains("if (sizeKey === lastPublishedSize) return"),
+ "{response}"
+ );
+ assert!(
+ response.contains("new ResizeObserver(schedule)"),
+ "{response}"
+ );
+ assert!(
+ response.contains("document.addEventListener('load', schedule, true)"),
+ "{response}"
+ );
+ assert!(
+ response.contains("document.fonts?.ready.then(schedule)"),
+ "{response}"
+ );
+ assert!(
+ response.contains("const maxContentProbes = 512"),
+ "{response}"
+ );
+ assert!(
+ response.contains("if (!document.hidden) schedule()"),
+ "{response}"
+ );
+ assert!(response.contains("}, 500)"), "{response}");
+ assert!(response.contains("truncated || !bodyCoupled"), "{response}");
+ assert!(
+ response.contains("extentDelta * viewportDelta > 0"),
+ "{response}"
+ );
+ assert!(
+ response.contains("previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge"),
+ "{response}"
+ );
+ assert!(
+ response.contains("previous?.sizeBaseline\n ?? previous?.baseline"),
+ "{response}"
+ );
+ assert!(
+ response.contains("previous?.minimumBaseline\n ?? previous?.baseline"),
+ "{response}"
+ );
+ assert!(
+ response.contains("element, widthExtent, heightExtent, viewportWidth, viewportHeight"),
+ "{response}"
+ );
+ assert!(
+ response.contains("heightExtent, style.height, style.minHeight, viewportHeight"),
+ "{response}"
+ );
+ assert!(
+ response.contains("truncated: element !== null"),
+ "{response}"
+ );
+ assert!(
+ response.contains("intrinsicBodyWidth, bodyCoupling.width,"),
+ "{response}"
+ );
+ assert!(
+ response.contains("const rootRect = root?.getBoundingClientRect()"),
+ "{response}"
+ );
+ assert!(
+ response.contains("intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody,"),
+ "{response}"
+ );
+ assert!(response.contains("rootHeightMirrorsBody"), "{response}");
+ assert!(response.contains("rootIndependentLayout"), "{response}");
+ assert!(response.contains("computedStyleMap?.()"), "{response}");
+ assert!(
+ response.contains("rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0"),
+ "{response}"
+ );
+ assert!(
+ response.contains("contentResolutionState.width, viewportWidth, scrollWidth"),
+ "{response}"
+ );
+ assert!(
+ response.contains("contentResolutionState.height, viewportHeight, scrollHeight"),
+ "{response}"
+ );
+ assert!(
+ response.contains("? Math.max(coupledBaseline || 0, state.resolved || 1)"),
+ "{response}"
+ );
+ assert!(
+ response.contains("resetLayoutCouplingSamples(state.axis)"),
+ "{response}"
+ );
+ assert!(
+ response.contains("state.responsiveCandidateCount >= 2"),
+ "{response}"
+ );
+ assert!(
+ response.contains("} else if (candidateContradicted)"),
+ "{response}"
+ );
+ assert!(
+ response.contains("viewportChanged && !hostAppliedPrevious"),
+ "{response}"
+ );
+ assert!(!response.contains("MutationObserver"), "{response}");
+ assert!(!response.contains("childList: true"), "{response}");
+
+ let resolve_intrinsic_extent =
+ |body: u32, body_coupled: bool, content: u32, truncated: bool| {
+ if truncated || !body_coupled {
+ body.max(content)
+ } else {
+ content
+ }
+ };
+ let resolve_document_extent = |root: u32,
+ root_coupled: bool,
+ root_mirrors_body: bool,
+ body: u32,
+ body_coupled: bool,
+ content: u32,
+ truncated: bool| {
+ let body_and_content = resolve_intrinsic_extent(body, body_coupled, content, truncated);
+ resolve_intrinsic_extent(
+ root,
+ root_coupled || root_mirrors_body,
+ body_and_content,
+ truncated,
+ )
+ };
+ let resolve_document_baseline =
+ |root_baseline: i32, root_mirrors_body: bool, body_baseline: i32, content_baseline: i32| {
+ (if root_mirrors_body { 0 } else { root_baseline })
+ .max(body_baseline)
+ .max(content_baseline)
+ };
+ let root_mirrors_body = |same_extent: bool,
+ typed_size: Option<&str>,
+ typed_minimum: Option<&str>,
+ independent_latch: &mut bool| {
+ let typed_available = typed_size.is_some() || typed_minimum.is_some();
+ if typed_available {
+ let size_is_independent = typed_size.is_some_and(|value| value != "auto");
+ let minimum_is_independent = typed_minimum
+ .is_some_and(|value| value != "auto" && value != "0px" && value != "0%");
+ *independent_latch = size_is_independent || minimum_is_independent;
+ } else if !same_extent {
+ *independent_latch = true;
+ }
+ same_extent && !*independent_latch
+ };
+ let resolve_viewport_coupling = |extent: i32,
+ viewport: i32,
+ previous_extent: i32,
+ previous_viewport: i32,
+ previous_coupled: bool| {
+ let viewport_delta = viewport - previous_viewport;
+ if viewport_delta.abs() < 1 {
+ if (extent - previous_extent).abs() < 1 {
+ previous_coupled
+ } else {
+ false
+ }
+ } else {
+ let extent_delta = extent - previous_extent;
+ extent_delta.abs() >= 1 && extent_delta * viewport_delta > 0
+ }
+ };
+ let fixed_body_edge_coupled = resolve_viewport_coupling(2_000, 2_000, 2_000, 700, false);
+ assert!(!fixed_body_edge_coupled);
+ let viewport_min_height_coupled = resolve_viewport_coupling(2_000, 2_000, 700, 700, false);
+ assert!(viewport_min_height_coupled);
+ let bottom_edge_coupled = resolve_viewport_coupling(2_000, 2_000, 700, 700, false);
+ assert!(bottom_edge_coupled);
+ assert!(resolve_viewport_coupling(2_000, 2_000, 2_000, 2_000, true));
+ assert!(!resolve_viewport_coupling(1_000, 2_000, 2_000, 2_000, true));
+ assert_eq!(resolve_intrinsic_extent(2_000, true, 1_000, false), 1_000);
+ assert_eq!(resolve_intrinsic_extent(2_000, true, 0, false), 0);
+ assert_eq!(resolve_intrinsic_extent(2_000, true, 0, false).max(1), 1);
+ assert_eq!(resolve_intrinsic_extent(2_000, false, 1_000, false), 2_000);
+ assert_eq!(resolve_intrinsic_extent(2_000, false, 2_000, false), 2_000);
+ assert_eq!(resolve_intrinsic_extent(10_000, true, 5_120, true), 10_000);
+ assert_eq!(
+ resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false),
+ 2_000,
+ "a fixed documentElement extent must survive body/descendant aggregation"
+ );
+ assert_eq!(
+ resolve_document_extent(2_000, false, true, 2_000, true, 1_000, false),
+ 1_000,
+ "a documentElement extent mirrored from a responsive body must not become a sticky floor"
+ );
+ assert_eq!(
+ resolve_document_extent(2_000, false, true, 2_000, false, 1_000, false),
+ 2_000,
+ "equal fixed root/body extents must be preserved by the non-coupled body"
+ );
+ let mut typed_root_independent = false;
+ assert!(!root_mirrors_body(
+ true,
+ Some("2000px"),
+ Some("0px"),
+ &mut typed_root_independent,
+ ));
+ assert_eq!(
+ resolve_document_extent(2_000, false, false, 2_000, true, 1_000, false),
+ 2_000,
+ "a fixed root equal to a coupled body must remain independently authoritative"
+ );
+ let mut typed_root_auto = false;
+ assert!(root_mirrors_body(
+ true,
+ Some("auto"),
+ Some("0px"),
+ &mut typed_root_auto,
+ ));
+ let mut fallback_root_independent = false;
+ assert!(root_mirrors_body(
+ true,
+ None,
+ None,
+ &mut fallback_root_independent,
+ ));
+ assert!(!root_mirrors_body(
+ false,
+ None,
+ None,
+ &mut fallback_root_independent,
+ ));
+ assert!(!root_mirrors_body(
+ true,
+ None,
+ None,
+ &mut fallback_root_independent,
+ ));
+
+ #[derive(Default)]
+ struct ResolutionState {
+ viewport: Option,
+ scroll: Option,
+ resolved: Option,
+ responsive_floor: Option,
+ responsive: bool,
+ responsive_candidate_count: u8,
+ responsive_candidate_floor: Option,
+ responsive_candidate_direction: i32,
+ }
+ let resolve_content_extent = |state: &mut ResolutionState,
+ viewport: i32,
+ scroll: i32,
+ intrinsic: i32,
+ probed: i32,
+ truncated: bool,
+ coupled_baseline: Option| {
+ let viewport_changed = state
+ .viewport
+ .is_some_and(|previous| (viewport - previous).abs() >= 1);
+ let host_applied_previous = state
+ .resolved
+ .is_some_and(|previous| (viewport - previous).abs() <= 1);
+ let native_reset = viewport_changed && !host_applied_previous;
+ if native_reset {
+ state.responsive = false;
+ state.responsive_floor = None;
+ state.responsive_candidate_count = 0;
+ state.responsive_candidate_floor = None;
+ state.responsive_candidate_direction = 0;
+ }
+ let viewport_delta = state.viewport.map_or(0, |previous| viewport - previous);
+ let scroll_delta = state.scroll.map_or(0, |previous| scroll - previous);
+ let responsive_scroll = !native_reset
+ && host_applied_previous
+ && viewport_delta.abs() >= 1
+ && scroll_delta.abs() >= 1
+ && viewport_delta * scroll_delta > 0;
+ let candidate_contradicted = !state.responsive
+ && state.responsive_candidate_count > 0
+ && ((viewport_changed && host_applied_previous && !responsive_scroll)
+ || (scroll_delta.abs() >= 1
+ && state.responsive_candidate_direction * scroll_delta < 0));
+ if responsive_scroll && !state.responsive {
+ let candidate_floor = if truncated {
+ Some(
+ coupled_baseline
+ .unwrap_or(0)
+ .max(state.resolved.unwrap_or(1)),
+ )
+ } else {
+ coupled_baseline.or(state.resolved)
+ };
+ if truncated {
+ state.responsive = true;
+ state.responsive_floor = candidate_floor;
+ state.responsive_candidate_count = 0;
+ state.responsive_candidate_floor = None;
+ state.responsive_candidate_direction = 0;
+ } else {
+ if state.responsive_candidate_count == 0 {
+ state.responsive_candidate_direction = viewport_delta.signum();
+ }
+ state.responsive_candidate_count += 1;
+ state.responsive_candidate_floor =
+ state.responsive_candidate_floor.or(candidate_floor);
+ if state.responsive_candidate_count >= 2 {
+ state.responsive = true;
+ state.responsive_floor = state.responsive_candidate_floor;
+ state.responsive_candidate_count = 0;
+ state.responsive_candidate_floor = None;
+ state.responsive_candidate_direction = 0;
+ }
+ }
+ } else if candidate_contradicted {
+ state.responsive_candidate_count = 0;
+ state.responsive_candidate_floor = None;
+ state.responsive_candidate_direction = 0;
+ }
+ let resolved = if state.responsive {
+ state
+ .responsive_floor
+ .unwrap_or(1)
+ .max(if truncated { probed } else { intrinsic })
+ } else if scroll > viewport {
+ scroll
+ } else {
+ intrinsic
+ };
+ if state.responsive && truncated {
+ state.responsive_floor = Some(state.responsive_floor.unwrap_or(1).max(resolved));
+ }
+ state.viewport = Some(viewport);
+ state.scroll = Some(scroll);
+ state.resolved = Some(resolved);
+ resolved
+ };
+
+ let mut proportional_vh = ResolutionState::default();
+ assert_eq!(
+ resolve_content_extent(&mut proportional_vh, 700, 840, 840, 0, false, None),
+ 840
+ );
+ assert_eq!(
+ resolve_content_extent(&mut proportional_vh, 840, 1_008, 0, 0, false, Some(840),),
+ 1_008
+ );
+ assert_eq!(
+ resolve_content_extent(&mut proportional_vh, 840, 1_008, 0, 0, false, Some(840),),
+ 1_008
+ );
+ assert_eq!(
+ resolve_content_extent(&mut proportional_vh, 1_008, 1_210, 0, 0, false, Some(840),),
+ 840
+ );
+ assert_eq!(
+ resolve_content_extent(&mut proportional_vh, 840, 1_008, 840, 840, false, Some(840),),
+ 840
+ );
+
+ let mut calculated_vh = ResolutionState::default();
+ assert_eq!(
+ resolve_content_extent(&mut calculated_vh, 700, 900, 900, 0, false, None),
+ 900
+ );
+ assert_eq!(
+ resolve_content_extent(&mut calculated_vh, 900, 1_100, 0, 0, false, Some(900),),
+ 1_100
+ );
+ assert_eq!(
+ resolve_content_extent(&mut calculated_vh, 1_100, 1_300, 0, 0, false, Some(900),),
+ 900
+ );
+
+ let mut fixed = ResolutionState::default();
+ assert_eq!(
+ resolve_content_extent(&mut fixed, 700, 2_000, 2_000, 2_000, false, None),
+ 2_000
+ );
+ assert_eq!(
+ resolve_content_extent(&mut fixed, 2_000, 2_000, 2_000, 2_000, false, None),
+ 2_000
+ );
+
+ let mut fixed_root = ResolutionState::default();
+ let native_fixed_root =
+ resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false) as i32;
+ assert_eq!(
+ resolve_content_extent(
+ &mut fixed_root,
+ 700,
+ 2_000,
+ native_fixed_root,
+ 1_000,
+ false,
+ None,
+ ),
+ 2_000
+ );
+ let fitted_fixed_root =
+ resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false) as i32;
+ assert_eq!(
+ resolve_content_extent(
+ &mut fixed_root,
+ 2_000,
+ 2_000,
+ fitted_fixed_root,
+ 1_000,
+ false,
+ None,
+ ),
+ 2_000,
+ "fitting the viewport must not discard a fixed documentElement extent"
+ );
+
+ let mut fixed_grows_during_fit = ResolutionState::default();
+ assert_eq!(
+ resolve_content_extent(
+ &mut fixed_grows_during_fit,
+ 700,
+ 2_000,
+ 2_000,
+ 2_000,
+ false,
+ None,
+ ),
+ 2_000
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut fixed_grows_during_fit,
+ 2_000,
+ 2_500,
+ 0,
+ 0,
+ false,
+ Some(2_000),
+ ),
+ 2_500
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut fixed_grows_during_fit,
+ 2_500,
+ 2_500,
+ 2_500,
+ 2_500,
+ false,
+ None,
+ ),
+ 2_500
+ );
+
+ assert_eq!(
+ resolve_content_extent(
+ &mut proportional_vh,
+ 840,
+ 1_500,
+ 1_500,
+ 1_500,
+ false,
+ Some(840),
+ ),
+ 1_500
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut proportional_vh,
+ 1_500,
+ 1_800,
+ 1_500,
+ 1_500,
+ false,
+ Some(840),
+ ),
+ 1_500
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut proportional_vh,
+ 1_500,
+ 1_800,
+ 500,
+ 500,
+ false,
+ Some(840),
+ ),
+ 840
+ );
+ assert_eq!(
+ resolve_content_extent(&mut proportional_vh, 800, 960, 960, 960, false, None,),
+ 960
+ );
+ assert_eq!(
+ resolve_content_extent(&mut proportional_vh, 960, 1_152, 0, 0, false, None,),
+ 1_152
+ );
+ assert_eq!(
+ resolve_content_extent(&mut proportional_vh, 1_152, 1_382, 0, 0, false, Some(960),),
+ 960
+ );
+
+ let mut min_height = ResolutionState::default();
+ assert_eq!(
+ resolve_content_extent(&mut min_height, 700, 2_000, 2_000, 2_000, false, None),
+ 2_000
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut min_height,
+ 2_000,
+ 2_000,
+ 2_000,
+ 2_000,
+ false,
+ Some(700),
+ ),
+ 2_000
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut min_height,
+ 2_000,
+ 2_000,
+ 1_000,
+ 1_000,
+ false,
+ Some(700),
+ ),
+ 1_000
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut min_height,
+ 1_000,
+ 1_000,
+ 1_000,
+ 1_000,
+ false,
+ Some(700),
+ ),
+ 1_000
+ );
+ assert_eq!(
+ resolve_content_extent(&mut min_height, 1_000, 1_000, 700, 500, false, Some(700),),
+ 700
+ );
+ assert_eq!(
+ resolve_content_extent(&mut min_height, 700, 700, 700, 500, false, Some(700),),
+ 700
+ );
+
+ let mut mirrored_root_min_height = ResolutionState::default();
+ let native_mirrored_intrinsic =
+ resolve_document_extent(2_000, false, true, 2_000, false, 2_000, false) as i32;
+ assert_eq!(
+ resolve_content_extent(
+ &mut mirrored_root_min_height,
+ 700,
+ 2_000,
+ native_mirrored_intrinsic,
+ 2_000,
+ false,
+ None,
+ ),
+ 2_000
+ );
+ let fitted_mirrored_baseline = resolve_document_baseline(0, true, 700, 0);
+ let fitted_mirrored_intrinsic = fitted_mirrored_baseline
+ .max(resolve_document_extent(2_000, false, true, 2_000, true, 2_000, false) as i32);
+ assert_eq!(
+ resolve_content_extent(
+ &mut mirrored_root_min_height,
+ 2_000,
+ 2_000,
+ fitted_mirrored_intrinsic,
+ 2_000,
+ false,
+ Some(fitted_mirrored_baseline),
+ ),
+ 2_000
+ );
+ let shrunk_mirrored_intrinsic = fitted_mirrored_baseline
+ .max(resolve_document_extent(2_000, false, true, 2_000, true, 1_000, false) as i32);
+ assert_eq!(
+ resolve_content_extent(
+ &mut mirrored_root_min_height,
+ 2_000,
+ 2_000,
+ shrunk_mirrored_intrinsic,
+ 1_000,
+ false,
+ Some(fitted_mirrored_baseline),
+ ),
+ 1_000
+ );
+ let host_applied_mirrored_baseline = resolve_document_baseline(2_000, true, 700, 0);
+ let host_applied_mirrored_intrinsic = host_applied_mirrored_baseline
+ .max(resolve_document_extent(1_000, true, true, 1_000, true, 1_000, false) as i32);
+ assert_eq!(
+ resolve_content_extent(
+ &mut mirrored_root_min_height,
+ 1_000,
+ 1_000,
+ host_applied_mirrored_intrinsic,
+ 1_000,
+ false,
+ Some(host_applied_mirrored_baseline),
+ ),
+ 1_000,
+ "applying the shrink must not restore the mirrored root's old fitted baseline"
+ );
+
+ let mut truncated_responsive = ResolutionState::default();
+ assert_eq!(
+ resolve_content_extent(
+ &mut truncated_responsive,
+ 700,
+ 10_000,
+ 10_000,
+ 5_120,
+ false,
+ None,
+ ),
+ 10_000
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut truncated_responsive,
+ 10_000,
+ 12_000,
+ 5_120,
+ 5_120,
+ true,
+ Some(700),
+ ),
+ 10_000
+ );
+ assert_eq!(
+ resolve_content_extent(
+ &mut truncated_responsive,
+ 10_000,
+ 12_000,
+ 1_000,
+ 1_000,
+ true,
+ Some(700),
+ ),
+ 10_000
+ );
+}
+
+#[test]
+fn local_preview_bridge_injection_uses_real_html_context_and_supports_omitted_end_tags() {
+ let bridge = r#""#;
+ let cases = [
+ (
+ "missing body and html end tags",
+ "game",
+ format!("game{bridge}"),
+ ),
+ (
+ "doctype greater-than ends the token even inside a public identifier quote",
+ " y\">game",
+ format!(
+ " {bridge} y\">game"
+ ),
+ ),
+ (
+ "malformed doctype quote before script body decoy",
+ "game",
+ format!(
+ "game{bridge}"
+ ),
+ ),
+ (
+ "public identifier remainder contains a script body decoy",
+ " y\">game",
+ format!(
+ " y\">game{bridge}"
+ ),
+ ),
+ (
+ "doctype keyword accepts a missing whitespace parse error",
+ "game",
+ format!(
+ "game{bridge}"
+ ),
+ ),
+ (
+ "script string body decoy",
+ "game",
+ format!(
+ "game{bridge}"
+ ),
+ ),
+ (
+ "script double escaped body decoy",
+ "xafter
",
+ format!(
+ "x{bridge}after
"
+ ),
+ ),
+ (
+ "trailing comment body decoy",
+ "game",
+ format!(
+ "game{bridge}"
+ ),
+ ),
+ (
+ "comment end bang before script body decoy",
+ "game",
+ format!(
+ "game{bridge}"
+ ),
+ ),
+ (
+ "template body decoy",
+ "game",
+ format!(
+ "game{bridge}"
+ ),
+ ),
+ (
+ "raw text body decoy",
+ "game",
+ format!(
+ "game{bridge}"
+ ),
+ ),
+ (
+ "case insensitive real end tags",
+ "game",
+ format!(
+ "game{bridge}"
+ ),
+ ),
+ ];
+
+ for (label, input, expected) in cases {
+ let root = unique_project_path();
+ let actual = local_preview_html_response_body(&root, input);
+ assert_eq!(actual, expected, "{label}");
+ assert_eq!(actual.matches(bridge).count(), 1, "{label}");
+ fs::remove_dir_all(root).ok();
+ }
+}
+
+#[test]
+fn local_preview_bridge_injection_is_idempotent_and_ignores_inert_path_decoys() {
+ let bridge = r#""#;
+ let root = unique_project_path();
+ let first = local_preview_html_response_body(
+ &root,
+ "/__genarrative/local-preview-fit.jsgame",
+ );
+ assert_eq!(first.matches(bridge).count(), 1, "{first}");
+ assert!(first.contains(&format!("game{bridge}")));
+
+ let second = local_preview_html_response_body(&root, &first);
+ assert_eq!(second, first);
+ assert_eq!(second.matches(bridge).count(), 1, "{second}");
+ fs::remove_dir_all(root).ok();
+}
+
+#[test]
+fn local_preview_bridge_injection_uses_the_first_duplicate_src_attribute() {
+ let bridge = r#""#;
+ let root = unique_project_path();
+ let actual = local_preview_html_response_body(
+ &root,
+ "game",
+ );
+
+ assert!(actual.contains(&format!("game{bridge}")));
+ assert_eq!(actual.matches(bridge).count(), 1, "{actual}");
+ fs::remove_dir_all(root).ok();
+}
+
+#[test]
+fn local_preview_bridge_injection_ignores_inert_script_src_decoys() {
+ let bridge = r#""#;
+ let cases = [
+ (
+ "non executable type",
+ "",
+ ),
+ (
+ "nomodule classic script",
+ "",
+ ),
+ ];
+
+ for (label, decoy) in cases {
+ let root = unique_project_path();
+ let actual = local_preview_html_response_body(
+ &root,
+ &format!("{decoy}game"),
+ );
+ assert!(
+ actual.contains(&format!("game{bridge}")),
+ "{label}"
+ );
+ assert_eq!(
+ actual
+ .matches("/__genarrative/local-preview-fit.js")
+ .count(),
+ 2,
+ "{label}"
+ );
+ fs::remove_dir_all(root).ok();
+ }
+}
+
+#[test]
+fn local_preview_bridge_injection_stays_outside_unclosed_inert_html_contexts() {
+ let bridge = r#""#;
+ let cases = [
+ (
+ "comment",
+ "game