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:
@@ -186,6 +186,12 @@ export default function SlideCanvas({
|
||||
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
|
||||
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 으로
|
||||
// top-left origin scale 후 wrapper 안에 정확히 맞춤.
|
||||
const W_SCALED = SLIDE_W * scale;
|
||||
@@ -283,37 +289,21 @@ export default function SlideCanvas({
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={finalHtmlUrl}
|
||||
src={embeddedSrc}
|
||||
title="Phase Z 렌더 결과"
|
||||
className="w-full h-full border-0 block"
|
||||
scrolling="no"
|
||||
sandbox="allow-same-origin"
|
||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||
onLoad={(e) => {
|
||||
// final.html 은 standalone 표시용으로 body 에 padding / flex center /
|
||||
// min-height: 100vh 가 있어서, iframe 안에서는 슬라이드가 잘림.
|
||||
// .slide (1280×720) 만 보이도록 reset CSS 를 contentDocument 에 주입.
|
||||
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
|
||||
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
|
||||
// reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
|
||||
// backend auto-mode detection script 를 trigger 해서 html.embedded
|
||||
// class 를 붙이고 standalone-only body 규칙을 reset.
|
||||
try {
|
||||
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
||||
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 무변).
|
||||
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
|
||||
|
||||
@@ -2021,12 +2021,23 @@ def _attempt_salvage_chain(
|
||||
|
||||
def render_slide(slide_title: str, slide_footer: Optional[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.
|
||||
|
||||
layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag.
|
||||
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(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
@@ -2050,6 +2061,7 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||
layout_css=layout_css,
|
||||
gap_px=gap_px,
|
||||
token_css=_read_token_css(),
|
||||
embedded_mode=embedded_mode,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
<!-- 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. -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<html lang="ko"{% if embedded_mode == "embedded" %} class="embedded"{% endif %}>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1280">
|
||||
<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>
|
||||
/* ── existing tokens (inlined) ── */
|
||||
{{ token_css | safe }}
|
||||
@@ -20,6 +32,19 @@
|
||||
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) ── */
|
||||
.slide {
|
||||
width: 1280px; height: 720px;
|
||||
|
||||
69
tests/phase_z2/test_slide_base_embedded_mode.py
Normal file
69
tests/phase_z2/test_slide_base_embedded_mode.py
Normal 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")
|
||||
Reference in New Issue
Block a user