feat(IMP-14): A-4 — slide_base embedded vs standalone mode contract

Step 13 owns iframe-vs-standalone CSS contract in slide_base.html via
3-valued embedded_mode enum (auto / embedded / standalone). Removes
SlideCanvas.tsx runtime CSS injection workaround; frontend now passes
?embedded=1 query so auto-mode script attaches html.embedded class and
scopes the standalone body centering/min-height/padding reset.

- templates/phase_z2/slide_base.html: conditional html.embedded class +
  CSP-safe auto-mode <script> + additive html.embedded body/.slide rules
- src/phase_z2_pipeline.py: render_slide gains keyword-only embedded_mode
  ("auto" default) + ValueError guard; 3 existing call sites unchanged
- Front/client/src/components/SlideCanvas.tsx: derive embeddedSrc with
  ?embedded=1 (query-preserving), drop reset CSS injection block
- tests/phase_z2/test_slide_base_embedded_mode.py: 6 cases — auto script,
  CSS rules, embedded/standalone explicit modes, byte-determinism,
  invalid-mode guard
This commit is contained in:
2026-05-18 07:21:31 +09:00
parent 7d5639ad72
commit 7a52cebfaa
4 changed files with 120 additions and 24 deletions

View File

@@ -186,6 +186,12 @@ export default function SlideCanvas({
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드. // 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning; const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;
// IMP-14 (Step 13 A-4) — backend slide_base.html 가 embedded vs standalone CSS
// contract 를 `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
const embeddedSrc = finalHtmlUrl
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
: undefined;
// wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로 // wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로
// top-left origin scale 후 wrapper 안에 정확히 맞춤. // top-left origin scale 후 wrapper 안에 정확히 맞춤.
const W_SCALED = SLIDE_W * scale; const W_SCALED = SLIDE_W * scale;
@@ -283,37 +289,21 @@ export default function SlideCanvas({
> >
<iframe <iframe
ref={iframeRef} ref={iframeRef}
src={finalHtmlUrl} src={embeddedSrc}
title="Phase Z 렌더 결과" title="Phase Z 렌더 결과"
className="w-full h-full border-0 block" className="w-full h-full border-0 block"
scrolling="no" scrolling="no"
sandbox="allow-same-origin" sandbox="allow-same-origin"
style={{ pointerEvents: isEditMode ? "auto" : "none" }} style={{ pointerEvents: isEditMode ? "auto" : "none" }}
onLoad={(e) => { onLoad={(e) => {
// final.html 은 standalone 표시용으로 body 에 padding / flex center / // IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
// min-height: 100vh 가 있어서, iframe 안에서는 슬라이드가 잘림. // slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
// .slide (1280×720) 만 보이도록 reset CSS 를 contentDocument 에 주입. // reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
// backend auto-mode detection script 를 trigger 해서 html.embedded
// class 를 붙이고 standalone-only body 규칙을 reset.
try { try {
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument; const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
if (!doc) return; if (!doc) return;
const style = doc.createElement("style");
style.textContent = `
html, body {
margin: 0 !important;
padding: 0 !important;
min-height: 0 !important;
height: 720px !important;
width: 1280px !important;
background: transparent !important;
display: block !important;
overflow: hidden !important;
}
.slide {
box-shadow: none !important;
margin: 0 !important;
}
`;
doc.head.appendChild(style);
// 2026-05-14 — slide-level override CSS (catalog/template 무변). // 2026-05-14 — slide-level override CSS (catalog/template 무변).
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정. // Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.

View File

@@ -2021,12 +2021,23 @@ def _attempt_salvage_chain(
def render_slide(slide_title: str, slide_footer: Optional[str], def render_slide(slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str, zones_data: list[dict], layout_preset: str,
layout_css: dict, gap_px: int = GRID_GAP) -> str: layout_css: dict, gap_px: int = GRID_GAP,
*, embedded_mode: str = "auto") -> str:
"""Single slide HTML — slide_base.html + 8-preset layout vocabulary. """Single slide HTML — slide_base.html + 8-preset layout vocabulary.
layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag. layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag.
Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입. Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입.
embedded_mode (IMP-14): "auto" | "embedded" | "standalone". Controls
slide_base.html body CSS contract. Default "auto" preserves backward-compat
with run_overflow_check standalone path and lets iframe consumers signal via
?embedded=1 or window.self!==window.top.
""" """
if embedded_mode not in ("auto", "embedded", "standalone"):
raise ValueError(
f"render_slide: invalid embedded_mode={embedded_mode!r}; "
"expected one of 'auto', 'embedded', 'standalone'"
)
env = Environment( env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)), loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]), autoescape=select_autoescape(["html"]),
@@ -2050,6 +2061,7 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
layout_css=layout_css, layout_css=layout_css,
gap_px=gap_px, gap_px=gap_px,
token_css=_read_token_css(), token_css=_read_token_css(),
embedded_mode=embedded_mode,
) )

View File

@@ -1,11 +1,23 @@
<!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones). <!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones).
원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. --> 원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko"{% if embedded_mode == "embedded" %} class="embedded"{% endif %}>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=1280"> <meta name="viewport" content="width=1280">
<title>{{ slide_title }}</title> <title>{{ slide_title }}</title>
{% if embedded_mode == "auto" %}
<script>
(function(){
try {
var params = new URLSearchParams(window.location.search);
if (params.get('embedded') === '1' || window.self !== window.top) {
document.documentElement.classList.add('embedded');
}
} catch (e) {}
})();
</script>
{% endif %}
<style> <style>
/* ── existing tokens (inlined) ── */ /* ── existing tokens (inlined) ── */
{{ token_css | safe }} {{ token_css | safe }}
@@ -20,6 +32,19 @@
padding: 20px 0; padding: 20px 0;
} }
/* ── IMP-14 A-4: embedded mode reset (iframe consumer) ──
standalone-only body centering/min-height/padding undone so the .slide
(1280×720) sits at origin without vertical shift or clipping. */
html.embedded body {
background: transparent;
display: block;
min-height: 0;
padding: 0;
}
html.embedded .slide {
box-shadow: none;
}
/* ── 16:9 슬라이드 (single, 1280×720) ── */ /* ── 16:9 슬라이드 (single, 1280×720) ── */
.slide { .slide {
width: 1280px; height: 720px; width: 1280px; height: 720px;

View File

@@ -0,0 +1,69 @@
"""IMP-14 A-4 — slide_base.html embedded_mode contract tests.
Asserts the three-valued enum (auto / embedded / standalone) round-trips
through render_slide -> slide_base.html, that the additive html.embedded
CSS reset and the auto-mode detection <script> are emitted under the
correct modes, that the invalid-mode guard raises ValueError, and that
Jinja2 rendering is byte-deterministic across calls.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import render_slide
def _zone() -> dict:
return {"position": "primary", "template_id": "__empty__", "slot_payload": {}}
def _layout_css() -> dict:
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
def _render(embedded_mode: str = "auto") -> str:
return render_slide(
slide_title="t",
slide_footer=None,
zones_data=[_zone()],
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
embedded_mode=embedded_mode,
)
def test_auto_script_present():
html = _render("auto")
assert "params.get('embedded')" in html
assert "window.self !== window.top" in html
assert "classList.add('embedded')" in html
def test_css_rules_present():
html = _render("auto")
assert "html.embedded body" in html
assert "html.embedded .slide" in html
def test_embedded_mode_explicit():
html = _render("embedded")
assert '<html lang="ko" class="embedded">' in html
assert "params.get('embedded')" not in html
def test_standalone_mode_explicit():
html = _render("standalone")
assert '<html lang="ko">' in html
assert 'class="embedded"' not in html.split("</head>")[0]
assert "params.get('embedded')" not in html
def test_deterministic():
assert _render("embedded") == _render("embedded")
assert _render("auto") == _render("auto")
def test_invalid_mode_raises():
with pytest.raises(ValueError, match="invalid embedded_mode"):
_render("bogus")