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 모드.
|
// 슬라이드 박스 표시 조건 — 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 비율 등) 지정.
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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