Compare commits

47 changed files with 2664 additions and 3866 deletions
+2
View File
@@ -7,6 +7,8 @@ __pycache__/
dist/
build/
.venv/
.orchestrator/
tmpim*.json/
node_modules/
data/
+22 -46
View File
@@ -186,12 +186,6 @@ 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;
@@ -289,21 +283,37 @@ export default function SlideCanvas({
>
<iframe
ref={iframeRef}
src={embeddedSrc}
src={finalHtmlUrl}
title="Phase Z 렌더 결과"
className="w-full h-full border-0 block"
scrolling="no"
sandbox="allow-same-origin"
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
onLoad={(e) => {
// 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.
// final.html 은 standalone 표시용으로 body 에 padding / flex center /
// min-height: 100vh 가 있어서, iframe 안에서는 슬라이드가 잘림.
// .slide (1280×720) 만 보이도록 reset CSS 를 contentDocument 에 주입.
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 비율 등) 지정.
@@ -602,28 +612,6 @@ export default function SlideCanvas({
: null;
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
// IMP-11 u4: active frame lookup — distinct axis from preview.
// preview is shown only when override differs from default; active is
// always defined as override-if-present-else-default. Used by u5 to
// compare the active frame's catalog min_height_px against zone height.
const activeFrameId = overrideFrameId ?? defaultFrameId;
const activeCandidate = activeFrameId
? region?.frame_candidates?.find((c) => c.id === activeFrameId)
: undefined;
// IMP-11 u5: catalog min_height_px violation hint. height is already
// a fraction of SLIDE_H (1280x720 logical px coordinate space), so
// logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
// not re-multiplied (double-apply would shrink the comparison value).
// Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
const minHeightPx = activeCandidate?.minHeightPx ?? null;
const belowMinHeight =
isPendingLayout &&
minHeightPx != null &&
zoneHeightPx != null &&
zoneHeightPx < minHeightPx;
return (
<div
key={zone.id}
@@ -707,18 +695,6 @@ export default function SlideCanvas({
</>
)}
{/* IMP-11 u5: red border + 'min H Npx' badge when zone height
is below the active frame's catalog min_height_px. Visual
hint only, no clamp/resize behavior change. */}
{belowMinHeight && minHeightPx != null && (
<>
<div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
<span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
min H {minHeightPx}px
</span>
</>
)}
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
부 라벨 = backend zone position (top, bottom, primary). */}
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
-25
View File
@@ -527,31 +527,6 @@ export default function Home() {
>
{runMeta.status}
</span>
{runMeta.filtered_section_ids.length > 0 && (
<details className="relative">
<summary className="text-[10px] font-bold px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded uppercase tracking-wider cursor-pointer list-none">
Filtered: {runMeta.filtered_section_ids.length}
</summary>
<div className="absolute top-full mt-1 left-0 z-50 bg-white border border-slate-200 rounded shadow-lg p-3 w-96 max-h-96 overflow-y-auto">
{runMeta.filtered_section_reasons.map((r, i) => (
<div key={i} className="mb-2 pb-2 border-b border-slate-100 last:border-0 last:mb-0 last:pb-0 text-[11px]">
<div className="font-mono text-slate-700">{r.section_ids.join(", ")}</div>
<div className="text-slate-500">selection_state: <span className="font-mono">{r.selection_state}</span></div>
{r.merge_type && <div className="text-slate-500">merge_type: <span className="font-mono">{r.merge_type}</span></div>}
{r.template_id && <div className="text-slate-500">template_id: <span className="font-mono">{r.template_id}</span></div>}
{r.v4_label && <div className="text-slate-500">v4_label: <span className="font-mono">{r.v4_label}</span></div>}
{r.phase_z_status && <div className="text-slate-500">phase_z_status: <span className="font-mono">{r.phase_z_status}</span></div>}
{r.score !== null && <div className="text-slate-500">score: <span className="font-mono">{r.score}</span></div>}
{r.source && <div className="text-slate-500">source: <span className="font-mono">{r.source}</span></div>}
{r.position && <div className="text-slate-500">position: <span className="font-mono">{r.position}</span></div>}
<ul className="mt-1 list-disc list-inside text-slate-600">
{r.filter_reasons.map((reason, j) => <li key={j} className="font-mono">{reason}</li>)}
</ul>
</div>
))}
</div>
</details>
)}
</>
)}
</div>
@@ -207,22 +207,6 @@ export async function exportSlidePlan(slidePlan: SlidePlan, userSelection: any):
// step20_slide_status.json → 최종 상태 (PASS / RENDERED_WITH_VISUAL_REGRESSION / ...)
// ─────────────────────────────────────────────────────────────────────────────
// IMP-10 D-1 : verbatim mirror of step20_slide_status.json.data.filtered_section_reasons[]
// schema (src/phase_z2_pipeline.py:2217-2278). `source` / `position` only present on
// the override-uncovered additive variant. Strings rendered verbatim — no enum redefinition.
export interface FilteredSectionReason {
section_ids: string[];
merge_type: string | null;
template_id: string | null;
v4_label: string | null;
phase_z_status: string | null;
score: number | null;
selection_state: string;
filter_reasons: string[];
source?: string;
position?: string | null;
}
export interface RunMeta {
run_id: string;
mdx_path: string;
@@ -230,8 +214,6 @@ export interface RunMeta {
status: "PASS" | "RENDERED_WITH_VISUAL_REGRESSION" | "PARTIAL_COVERAGE" | "ABORTED" | string;
visual_check_passed: boolean;
full_mdx_coverage: boolean;
filtered_section_ids: string[]; // step20 filtered_section_ids
filtered_section_reasons: FilteredSectionReason[]; // step20 filtered_section_reasons
preview_url: string; // /data/runs/{runId}/preview.png
final_html_url: string; // /data/runs/{runId}/final.html
layout_candidates: string[]; // step07 layout_candidates list
@@ -411,8 +393,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
status: slideStatus.data?.overall ?? "UNKNOWN",
visual_check_passed: slideStatus.data?.visual_check_passed ?? false,
full_mdx_coverage: slideStatus.data?.full_mdx_coverage ?? false,
filtered_section_ids: slideStatus.data?.filtered_section_ids ?? [],
filtered_section_reasons: slideStatus.data?.filtered_section_reasons ?? [],
preview_url: `${base}/preview.png`,
final_html_url: `${base}/final.html`,
layout_candidates: layout.data?.layout_candidates ?? [],
@@ -527,10 +507,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
catalogRegistered: c.catalog_registered,
// backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px).
// logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined.
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined (graceful).
minHeightPx: c.min_height_px ?? undefined,
}));
const displayStrategy = (
-4
View File
@@ -127,10 +127,6 @@ export interface FrameCandidate {
/** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override
* 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */
catalogRegistered?: boolean;
/** IMP-11 D-2 — frame contract visual_hints.min_height_px (logical 1280x720 px).
* Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px.
* Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */
minHeightPx?: number;
}
// ─────────────────────────────────────────────────────────────────────────────
+25 -198
View File
@@ -5,205 +5,32 @@ sidebar:
---
## 1. DX에 대한 인식
<br/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifycontent: 'center' }}>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>기술 및 소프트웨어 이해도</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #1a73e8',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.</li>
<li>3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.</li>
<li>필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>효과와 효율성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #d93025',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.</li>
<li>BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.</li>
<li>결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>인력 및 교육</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #f9ab00',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.</li>
<li>신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.</li>
<li>여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>경제적 부담</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #34a853',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?</li>
<li>모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.</li>
<li>디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?</li>
</ul>
</div>
<div style={{
width: '100%',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default',
marginTop: '10px'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>실무 및 적용성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #8e24aa',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.</li>
<li>지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.</li>
<li>속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.</li>
</ul>
</div>
</div>
- **기술 및 소프트웨어 이해도**
- "무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"
- 기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.
- 3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.
- 필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?
- **효과와 효율성**
- "2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"
- 성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.
- BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.
- 결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?
- **인력 및 교육**
- "수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"
- 교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.
- 신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.
- 여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.
- **경제적 부담**
- "S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"
- 사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?
- 모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.
- 디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?
- **실무 및 적용성**
- "실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"
- 구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.
- 지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.
- 속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.
<br/>
---
<br/>
+25 -198
View File
@@ -5,205 +5,32 @@ sidebar:
---
## 1. DX에 대한 인식
<br/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifycontent: 'center' }}>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>기술 및 소프트웨어 이해도</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #1a73e8',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.</li>
<li>3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.</li>
<li>필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>효과와 효율성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #d93025',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.</li>
<li>BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.</li>
<li>결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>인력 및 교육</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #f9ab00',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.</li>
<li>신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.</li>
<li>여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>경제적 부담</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #34a853',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?</li>
<li>모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.</li>
<li>디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?</li>
</ul>
</div>
<div style={{
width: '100%',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default',
marginTop: '10px'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>실무 및 적용성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #8e24aa',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.</li>
<li>지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.</li>
<li>속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.</li>
</ul>
</div>
</div>
- **기술 및 소프트웨어 이해도**
- "무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"
- 기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.
- 3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.
- 필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?
- **효과와 효율성**
- "2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"
- 성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.
- BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.
- 결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?
- **인력 및 교육**
- "수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"
- 교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.
- 신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.
- 여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.
- **경제적 부담**
- "S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"
- 사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?
- 모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.
- 디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?
- **실무 및 적용성**
- "실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"
- 구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.
- 지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.
- 속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.
<br/>
---
<br/>
-189
View File
@@ -1,189 +0,0 @@
"""IMP-13 build-time preview.png renderer for figma_to_html_agent/blocks/<frame_id> (u1-u6)."""
from __future__ import annotations
import argparse, hashlib, json, sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_BLOCKS_DIR = REPO_ROOT / "figma_to_html_agent" / "blocks"
DEFAULT_MANIFEST = DEFAULT_BLOCKS_DIR / "_preview_manifest.json"
@dataclass(frozen=True)
class FrameRow:
frame_id: str
block_dir: Path
index_html_path: Path
preview_png_path: Path
has_index: bool
has_preview: bool
def discover(blocks_dir: Path) -> List[FrameRow]:
if not blocks_dir.is_dir():
return []
rows: List[FrameRow] = []
for entry in sorted(blocks_dir.iterdir()):
if not entry.is_dir():
continue
idx, png = entry / "index.html", entry / "preview.png"
rows.append(FrameRow(entry.name, entry, idx, png, idx.is_file(), png.is_file()))
return rows
def _build_driver() -> Any:
"""Headless Chrome driver. Mirrors the run_overflow_check chromedriver-candidate + headless options pattern.
Inline per Stage 2 (no shared module). Per-frame window-size is set by the caller (u3), not here."""
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
candidates = [REPO_ROOT / "chromedriver", REPO_ROOT / "chromedriver.exe"]
last_err: Exception | None = None
for path in candidates:
if path.is_file():
try:
return webdriver.Chrome(service=Service(str(path)), options=options)
except Exception as exc: # noqa: BLE001 — propagate via aggregated error
last_err = exc
try:
return webdriver.Chrome(options=options)
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"selenium init failed: {last_err or exc}") from exc
def render_one(driver: Any, row: FrameRow) -> tuple[int, int, Path]:
"""Render row.index_html_path -> row.preview_png_path via WebElement screenshot. Returns (w, h, path) or raises.
Driver is injected (caller owns lifecycle). .slide bbox drives window-size; no hardcoded slide dimensions."""
if not row.has_index:
raise FileNotFoundError(f"missing index.html: {row.index_html_path}")
from selenium.webdriver.common.by import By
driver.get(row.index_html_path.resolve().as_uri())
driver.set_script_timeout(15)
driver.execute_async_script(
"const cb=arguments[arguments.length-1];"
"(document.fonts&&document.fonts.ready?document.fonts.ready:Promise.resolve()).then(()=>cb(true));"
)
rect = driver.execute_script(
"const el=document.querySelector('.slide');"
"if(!el)return null;"
"const r=el.getBoundingClientRect();"
"return [Math.round(r.width), Math.round(r.height)];"
)
if not rect:
raise RuntimeError(f".slide not found in {row.index_html_path}")
w, h = int(rect[0]), int(rect[1])
driver.set_window_size(w, h)
el = driver.find_element(By.CSS_SELECTOR, ".slide")
row.preview_png_path.write_bytes(el.screenshot_as_png)
return w, h, row.preview_png_path
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def is_unchanged(row: FrameRow, last_entry: Optional[Dict[str, Any]]) -> bool:
"""Stale-detect short-circuit: True iff preview.png mtime >= index.html mtime AND sha256 matches last_entry.
Returns False when prior entry is absent, preview.png is missing, preview is older than index, or hash differs."""
if last_entry is None or not row.has_index or not row.has_preview:
return False
try:
idx_mtime = row.index_html_path.stat().st_mtime
png_mtime = row.preview_png_path.stat().st_mtime
except OSError:
return False
if png_mtime < idx_mtime:
return False
recorded = last_entry.get("index_sha256")
if not recorded:
return False
return _sha256_file(row.index_html_path) == recorded
def categorize(rows: List[FrameRow]) -> Dict[str, List[FrameRow]]:
"""Bucket discover() rows so nothing is silently skipped (Stage 2 guardrail).
renderable = has_index (eligible for render or skipped_unchanged decision in u6).
missing_index_html = no index.html (catalog gap; IMP-04 follow-up).
orphan = preview.png exists without index.html (subset of missing_index_html; stale artifact to flag).
Buckets are intentionally non-disjoint: orphan is a subset of missing_index_html,
matching the Stage 2 evidence counts (renderable=20, missing_index_html=13, orphan=1)."""
renderable = [r for r in rows if r.has_index]
missing = [r for r in rows if not r.has_index]
orphan = [r for r in missing if r.has_preview]
return {"renderable": renderable, "missing_index_html": missing, "orphan": orphan}
def _load_manifest(path: Path) -> Dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def _render_entry(row: FrameRow, w: int, h: int) -> Dict[str, Any]:
return {"status": "rendered", "index_sha256": _sha256_file(row.index_html_path),
"index_mtime": row.index_html_path.stat().st_mtime,
"preview_mtime": row.preview_png_path.stat().st_mtime,
"viewport": {"w": w, "h": h}}
def main(argv: Iterable[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="generate_frame_previews", description="IMP-13 build-time preview.png renderer.")
p.add_argument("--blocks-dir", type=Path, default=DEFAULT_BLOCKS_DIR)
p.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
p.add_argument("--dry-run", action="store_true")
args = p.parse_args(list(argv) if argv is not None else None)
rows = discover(args.blocks_dir)
if args.dry_run:
wi = sum(1 for r in rows if r.has_index)
wp = sum(1 for r in rows if r.has_preview)
print(f"discovered: total={len(rows)} with_index_html={wi} with_preview_png={wp}")
return 0
prev_frames = _load_manifest(args.manifest).get("frames") or {}
buckets = categorize(rows)
frames: Dict[str, Dict[str, Any]] = {}
counts = {"rendered": 0, "skipped_unchanged": 0, "error": 0}
driver = None
try:
for r in buckets["renderable"]:
last = prev_frames.get(r.frame_id) if isinstance(prev_frames, dict) else None
if is_unchanged(r, last):
frames[r.frame_id] = {**last, "status": "skipped_unchanged"}
counts["skipped_unchanged"] += 1
continue
if driver is None:
driver = _build_driver()
try:
w, h, _ = render_one(driver, r)
frames[r.frame_id] = _render_entry(r, w, h)
counts["rendered"] += 1
except Exception as exc: # noqa: BLE001
frames[r.frame_id] = {"status": "error", "error": str(exc)}
counts["error"] += 1
finally:
if driver is not None:
try: driver.quit()
except Exception: pass
orphan_ids = {r.frame_id for r in buckets["orphan"]}
for r in buckets["missing_index_html"]:
frames[r.frame_id] = {"status": "orphan" if r.frame_id in orphan_ids else "missing_index_html", "has_preview": r.has_preview}
summary = {"total": len(rows), "renderable": len(buckets["renderable"]), "missing_index_html": len(buckets["missing_index_html"]), "orphan": len(buckets["orphan"]), **counts}
payload = {"schema": 1, "generated_at": datetime.now(timezone.utc).isoformat(), "blocks_dir": str(args.blocks_dir), "summary": summary, "frames": frames}
args.manifest.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
print(f"coverage: total={summary['total']} renderable={summary['renderable']} rendered={counts['rendered']} skipped_unchanged={counts['skipped_unchanged']} missing_index_html={summary['missing_index_html']} orphan={summary['orphan']} error={counts['error']}")
return 1 if counts["error"] else 0
if __name__ == "__main__":
sys.exit(main())
+21 -101
View File
@@ -7,29 +7,20 @@ A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 :
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가.
**잠근 매핑** (사용자 잠금 — 2026-05-17, IMP-12 u3 cascade) :
**잠근 매핑** (사용자 잠금 — 2026-04-29) :
| failure_type | next_proposed_action |
| failure_type | next_proposed_action |
|---|---|
| donor_slack_insufficient | cross_zone_redistribute |
| no_donor_candidates | cross_zone_redistribute |
| cross_zone_redistribute_insufficient | glue_compression |
| glue_absorption_insufficient | font_step_compression |
| font_step_insufficient | layout_adjust |
| rerender_still_fails | frame_reselect |
| not_attempted | none |
| donor_slack_insufficient | layout_adjust |
| no_donor_candidates | layout_adjust |
| rerender_still_fails | frame_reselect |
| not_attempted | none |
**escalation 단계 hierarchy** (Step 17 deterministic salvage cascade → layout/frame) :
**escalation 단계 hierarchy** (이번 기본 매핑이 따르는 원칙) :
```
cross_zone_redistribute (fit_verifier.redistribute — role-height adjustment)
layout_adjust (가장 가벼움 — zone 배치만 변경)
↓ 그래도 안 되면
glue_compression (SPACING_GLUE envelope, frame-scoped)
↓ 그래도 안 되면
font_step_compression (FONT_SIZE_STEPS, zone-scoped)
↓ 그래도 안 되면
layout_adjust (zone topology 변경)
↓ 그래도 안 되면
frame_reselect (V4 top-k 의 다른 frame)
frame_reselect (중간 — frame 자체 변경)
↓ 그래도 안 되면
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
```
@@ -62,65 +53,26 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
"현재 frame/zone 조합이 content 와 맞지 않음"
),
"cross_zone_redistribute_insufficient": (
"cross_zone_redistribute salvage step failed — fit_verifier.redistribute "
"could not find a feasible role-height adjustment within the frame envelope"
),
"glue_absorption_insufficient": (
"glue_compression salvage step failed — frame envelope cannot absorb "
"remaining overflow via SPACING_GLUE overrides (no global spacing shrink)"
),
"font_step_insufficient": (
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
"down to the floor without resolving overflow (or text_metrics missing)"
),
}
# ─── §A4-1b salvage_steps[-1].action → failure_type table ──────────
# u2 (IMP-12): _attempt_salvage_chain (u8) writes per-step records into
# retry_trace["salvage_steps"] with {action, passed, failure_reason}. classifier
# inspects salvage_steps[-1] so u3 can route 3 new types onto the cascade.
SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
}
# ─── §A4-2 next_action mapping (사용자 잠금) ──────────────────────
NEXT_ACTION_BY_FAILURE: dict[str, str] = {
"donor_slack_insufficient": "cross_zone_redistribute",
"no_donor_candidates": "cross_zone_redistribute",
"cross_zone_redistribute_insufficient": "glue_compression",
"glue_absorption_insufficient": "font_step_compression",
"font_step_insufficient": "layout_adjust",
"rerender_still_fails": "frame_reselect",
"not_attempted": "none",
"donor_slack_insufficient": "layout_adjust",
"no_donor_candidates": "layout_adjust",
"rerender_still_fails": "frame_reselect",
"not_attempted": "none",
}
NEXT_ACTION_RATIONALE: dict[str, str] = {
"donor_slack_insufficient": (
"primary donor slack 한도 도달 → cross_zone_redistribute 로 sibling zone "
"전체 role-height 재분배 (fit_verifier.redistribute). layout 변경은 cascade 끝"
"현재 layout 안 redistribution 끝남 → 다른 layout topology 검토 "
"(layout_adjust). frame 자체는 아직 의심 대상 X"
),
"no_donor_candidates": (
"단일 donor 후보 없음 → cross_zone_redistribute 로 role-height 전체 "
"재할당 시도 (fit_verifier.redistribute). layout 변경은 cascade 끝"
),
"cross_zone_redistribute_insufficient": (
"role-height 재분배도 frame envelope 못 맞춤 → glue_compression "
"(SPACING_GLUE frame-scoped) 으로 frame 내부 여백 축소"
),
"glue_absorption_insufficient": (
"frame 여백 envelope 도 부족 → font_step_compression "
"(FONT_SIZE_STEPS zone-scoped) 으로 폰트 한 단계 축소"
),
"font_step_insufficient": (
"deterministic salvage cascade 모두 소진 → layout_adjust 로 zone "
"topology 부터 재구성. frame_reselect 는 그 다음 단계"
"donor 자체 없거나 모두 막힘 → layout topology 부터 재구성하여 "
"sibling/space 다시 만들어 보는 게 우선 (layout_adjust). frame 변경은 그 다음"
),
"rerender_still_fails": (
"redistribution + rerender 까지 했는데도 visual fail → 현재 "
@@ -133,19 +85,10 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
}
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
# IMP-12 u7 (2026-05-18): 3 cascade salvage actions registered as IMPLEMENTED.
# plan/apply pairs live in phase_z2_retry (u4/u5/u6); pipeline orchestrator wiring
# (_attempt_salvage_chain) lands in u8/u9. router-level mapping is decoupled from
# orchestrator wiring on purpose so route_retry_failure → impl_status reflects
# the deterministic surface availability, not whether a given pipeline run has
# already invoked it.
NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
"layout_adjust": "MISSING",
"frame_reselect": "MISSING",
"none": "n/a",
"layout_adjust": "MISSING",
"frame_reselect": "MISSING",
"none": "n/a",
}
@@ -163,29 +106,6 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
if retry_trace.get("retry_passed"):
return None
# case 0.5 : salvage chain 자체 성공 — failure 없음 (u8/u9 wiring)
if retry_trace.get("salvage_passed"):
return None
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
salvage_steps = retry_trace.get("salvage_steps") or []
if salvage_steps:
last = salvage_steps[-1] or {}
if not last.get("passed"):
action = (last.get("action") or "").lower()
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
if ftype is not None:
reason = last.get("failure_reason") or ""
return {
"failure_type": ftype,
"classification_rule": (
f"salvage_steps[-1].action == {action!r} "
f"AND passed=False. raw failure_reason: {reason!r}"
),
}
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
if not retry_trace.get("retry_attempted"):
return {
@@ -284,7 +204,7 @@ def route_retry_failure(failure_type: str) -> dict:
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
next_action, "unknown"
),
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-05-17, IMP-12 u3 cascade)",
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-04-29)",
}
+21 -2
View File
@@ -383,8 +383,20 @@ def _build_process_product_pair(section, units, contract) -> dict:
payload: dict = {}
payload.update(_resolve_title(section, contract["payload"], contract))
# 2026-05-14 — popup link extraction. column body 안 `<details><summary>X</summary>` regex.
# 사용자 lock : 04-2 같은 통합 column 의 popup link 를 파란색 글씨 indicator 로 표시.
# popup 본 콘텐츠 (img) 는 별 axis — 본 mapping 은 summary text 만.
popup_pattern = re.compile(r"<details[^>]*>\s*<summary[^>]*>(.+?)</summary>", re.DOTALL)
# column index → popup slot name mapping (0 = left, 1 = right).
popup_slot_names = ["popup_left_summary", "popup_right_summary"]
for i, col in enumerate(cols):
sub_title, sub_body = units[i]
# popup summary extraction (column body 안에서 가장 가까운 details 의 summary).
m = popup_pattern.search(sub_body)
if m and i < len(popup_slot_names):
payload[popup_slot_names[i]] = m.group(1).strip()
parser_name = col["body_parser"]
parser = COLUMN_BODY_PARSERS.get(parser_name)
if parser is None:
@@ -428,8 +440,12 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
f"but ITEM_PARSERS has no such entry."
)
pad_to = options.get("pad_to", 4)
truncate_at = options.get("truncate_at", pad_to)
# 2026-05-14 — pad_to / truncate_at 가 null 이면 dynamic (콘텐츠 cardinality 그대로).
# frame 이 콘텐츠 cardinality 에 맞춰 보완·수정 룰. 기존 4 quadrant frame 들은
# default 4 유지 → backward compatible.
raw_pad_to = options.get("pad_to", 4)
raw_truncate_at = options.get("truncate_at", raw_pad_to)
truncate_at = len(units) if raw_truncate_at is None else raw_truncate_at
label_key = options.get("label_key_pattern", "quadrant_{n}_label")
body_key = options.get("body_key_pattern", "quadrant_{n}_body")
empty_label = options.get("empty_label", "")
@@ -440,6 +456,7 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
visible_units = list(units[:truncate_at])
parsed = [parser(u) for u in visible_units]
pad_to = len(parsed) if raw_pad_to is None else raw_pad_to
for i in range(pad_to):
n = i + 1
@@ -453,6 +470,8 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
if len(units) > truncate_at:
payload["_truncated_count"] = len(units) - truncate_at
# dynamic count for partial template 동적 loop
payload["_slot_count"] = pad_to
return payload
+25 -495
View File
@@ -58,19 +58,10 @@ from phase_z2_classifier import classify_visual_runtime_check
from phase_z2_router import route_fit_classification
from phase_z2_retry import (
DEFAULT_SAFETY_MARGIN_PX,
apply_cross_zone_redistribute_css,
apply_font_step_compression_css,
apply_glue_compression_css,
apply_retry_to_layout_css,
plan_cross_zone_redistribute,
plan_font_step_compression,
plan_glue_compression,
plan_zone_ratio_retry,
)
from phase_z2_failure_router import (
enrich_retry_trace_with_failure_classification,
route_retry_failure,
)
from phase_z2_failure_router import enrich_retry_trace_with_failure_classification
# trace-only runtime 연결 v0 — B1 → B4 chain.
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
@@ -128,11 +119,6 @@ GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07)
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
# Step 14 image_aspect_mismatch tolerance — |natural_ratio - rendered_ratio| > TOL ⇒ fail.
# Local anchor : IMP-15 실행-1 (Gitea issue #45) — image axis acceptance criteria.
# Spec doc row (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC) update deferred to IMP-15 실행-4.
IMAGE_ASPECT_DELTA_TOL = 0.05
# content_weight 계산 가중치
CONTENT_WEIGHT_COEFFS = {
"text_per_chars": 800, # text_len / 800 = score
@@ -967,56 +953,6 @@ def compute_zone_layout_cols(zones_data: list[dict],
}
def _aggregate_zone_signals_per_track(
preset: dict,
zones_data: list[dict],
) -> tuple[list[dict], list[dict]]:
"""Build per-row + per-col virtual zones for 2-D dynamic dispatch.
Each virtual zone aggregates content_weight.score (max) and
min_height_px (max) across single-span zones on that track
(occupied_rows == {r} for rows, occupied_cols == {c} for cols).
Falls back to all-span zones (touching every track on the axis)
when a track has no single-span zone.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
occupancy: list[tuple[dict, set[int], set[int]]] = []
for z in zones_data:
pos = z["position"]
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
occ_cols = {
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
}
occupancy.append((z, occ_rows, occ_cols))
def _track_virtual(idx: int, axis: str) -> dict:
if axis == "row":
single = [z for z, rr, _cc in occupancy if rr == {idx}]
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
else:
single = [z for z, _rr, cc in occupancy if cc == {idx}]
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
candidates = single or allspan
return {
"position": f"_virtual_{axis}_{idx}",
"template_id": f"_virtual_{axis}_{idx}",
"content_weight": {
"score": max(c["content_weight"]["score"] for c in candidates)
},
"min_height_px": max(
c.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
for c in candidates
),
}
row_virtuals = [_track_virtual(r, "row") for r in range(R)]
col_virtuals = [_track_virtual(c, "col") for c in range(C)]
return row_virtuals, col_virtuals
def _compute_per_zone_geometry(
layout_css: dict,
debug_zones: list[dict],
@@ -1147,61 +1083,6 @@ def _build_rows_dynamic(preset: dict, zones_data: list[dict],
}
def _build_grid_dynamic_2d(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""2-D dynamic path — dynamic row heights + dynamic column widths.
IMP-09 PR 2 (B-4) handler for the five preset families whose topology
is neither pure 'rows' nor pure 'cols':
- T (top-1-bottom-2)
- inverted-T (top-2-bottom-1)
- side-T-left (left-1-right-2)
- side-T-right (left-2-right-1)
- 2x2 (grid-2x2)
Strategy:
1) _aggregate_zone_signals_per_track builds R per-row + C per-col
virtual zones (max content_weight.score + max min_height_px of
single-span zones, falling back to all-span zones).
2) Row virtuals → compute_zone_layout → heights_px (R).
3) Col virtuals → compute_zone_layout_cols → widths_px (C).
4) Assemble layout_css dict with computation='2d_dynamic_aggregated'
and dynamic_rows=True, dynamic_cols=True.
raw_zone_layout carries both solver outputs + the virtual zone lists
so step08 trace can explain the per-track aggregation.
"""
row_virtuals, col_virtuals = _aggregate_zone_signals_per_track(
preset, zones_data
)
zl_row = compute_zone_layout(row_virtuals, gap=gap)
zl_col = compute_zone_layout_cols(col_virtuals, gap=gap)
heights_px = zl_row["heights_px"]
widths_px = zl_col["widths_px"]
rows_str = " ".join(f"{h}px" for h in heights_px)
cols_str = " ".join(f"{w}px" for w in widths_px)
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": rows_str,
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": "2d_dynamic_aggregated",
"dynamic_rows": True,
"dynamic_cols": True,
"raw_zone_layout": {
"row_layout": zl_row,
"col_layout": zl_col,
"row_virtuals": row_virtuals,
"col_virtuals": col_virtuals,
},
}
def _build_cols_dynamic(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""vertical-2 path — dynamic column widths, static fr row heights.
@@ -1232,105 +1113,6 @@ def _build_cols_dynamic(preset: dict, zones_data: list[dict],
}
def _override_to_grid_tracks(
preset: dict,
zones_data: list[dict],
override_zone_geometries: dict[str, dict],
gap: int = GRID_GAP,
) -> dict:
"""2-D override path — derive heights_px (R) + widths_px (C) from
user-supplied zone_id -> {x, y, w, h} (0~1 within slide-body).
IMP-09 PR 2 (B-4) override handler for the five preset families
whose topology is neither pure 'rows' nor pure 'cols':
- T (top-1-bottom-2)
- inverted-T (top-2-bottom-1)
- side-T-left (left-1-right-2)
- side-T-right (left-2-right-1)
- 2x2 (grid-2x2)
Strategy:
1) Parse css_areas into R x C grid.
2) For each row r: aggregate h via max over single-row zones
(occupied_rows == {r}); fallback to all-span zones; else 0.0.
3) For each col c: same with w.
4) Normalize per-axis (divide by total) and multiply by avail_*,
absorbing rounding diff into the last element.
5) If total_h or total_w == 0 (degenerate / empty override),
fall back to _build_grid_dynamic_2d default path.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
occupancy: list[tuple[dict, set[int], set[int]]] = []
for z in zones_data:
pos = z["position"]
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
occ_cols = {
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
}
occupancy.append((z, occ_rows, occ_cols))
def _track_value(idx: int, axis: str) -> float:
if axis == "row":
single = [z for z, rr, _cc in occupancy if rr == {idx}]
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
key = "h"
else:
single = [z for z, _rr, cc in occupancy if cc == {idx}]
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
key = "w"
candidates = single or allspan
vals = [
float(override_zone_geometries[z["position"]][key])
for z in candidates
if z["position"] in override_zone_geometries
]
return max(vals) if vals else 0.0
row_values = [_track_value(r, "row") for r in range(R)]
col_values = [_track_value(c, "col") for c in range(C)]
total_h = sum(row_values)
total_w = sum(col_values)
if total_h == 0 or total_w == 0:
return _build_grid_dynamic_2d(preset, zones_data, gap=gap)
row_ratios = [v / total_h for v in row_values]
col_ratios = [v / total_w for v in col_values]
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
heights_px = [int(round(r * avail_h)) for r in row_ratios]
widths_px = [int(round(r * avail_w)) for r in col_ratios]
diff_h = avail_h - sum(heights_px)
if diff_h != 0 and heights_px:
heights_px[-1] += diff_h
diff_w = avail_w - sum(widths_px)
if diff_w != 0 and widths_px:
widths_px[-1] += diff_w
rows_str = " ".join(f"{h}px" for h in heights_px)
cols_str = " ".join(f"{w}px" for w in widths_px)
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": rows_str,
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(rr, 3) for rr in row_ratios],
"width_ratios": [round(rr, 3) for rr in col_ratios],
"computation": "user_override_geometry",
"dynamic_rows": True,
"dynamic_cols": True,
"raw_zone_layout": {
"override_applied": True,
"source": override_zone_geometries,
},
}
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
@@ -1420,29 +1202,21 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
"dynamic_cols": True,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
elif topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
# IMP-09 PR 2 — 2-D override path (T / inverted-T / side-T / 2x2).
# Degenerate inputs (total_h == 0 or total_w == 0) fall back to
# _build_grid_dynamic_2d inside the helper.
return _override_to_grid_tracks(
preset, zones_data, override_zone_geometries, gap=gap
)
else:
# warn-and-fallthrough preserved for remaining presets (single).
# PR 3 territory.
# PR 1 lock — warn-and-fallthrough preserved.
# PR 2 promotes this to strict ValueError via _override_to_grid_tracks.
print(
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
f"(현재 horizontal-2 / vertical-2 / 2-D presets 만). default layout_css 사용.",
f"(현재 horizontal-2 / vertical-2 만). default layout_css 사용.",
file=sys.stderr,
)
# ── Dynamic branch — topology dispatch ──
# ── Dynamic branch — topology dispatch (PR 1: rows / cols only) ──
if topology == "rows":
return _build_rows_dynamic(preset, zones_data, gap)
if topology == "cols":
return _build_cols_dynamic(preset, zones_data, gap)
if topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
return _build_grid_dynamic_2d(preset, zones_data, gap)
# PR 2 will dispatch T / inverted-T / side-T-{left,right} / 2x2 here.
# PR 3 will dispatch single here.
return _build_fr_default(preset)
@@ -1939,110 +1713,14 @@ def _attempt_zone_ratio_retry(
return base_trace
# IMP-12 u8 — Step 17 salvage cascade orchestrator (deterministic, no normal-path AI).
# Plan/apply pairs: phase_z2_retry (u4/u5/u6). Routing: failure_router.route_retry_failure (u3).
# Pipeline wiring (cascade_inputs assembly + retry_trace merge) lands in u9.
_SALVAGE_FAIL_BY_ACTION = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
}
def _attempt_salvage_chain(
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str, layout_css: dict,
cascade_inputs: dict, initial_failure_type: str, gap_px: int,
) -> dict:
"""IMP-12 u8 — deterministic Step 17 salvage cascade (cross_zone → glue → font_step).
Per stage: plan → apply CSS → rerender → run_overflow_check. PASS promotes final.html;
cascade-exit (layout_adjust/frame_reselect/none) or all-fail preserves (b) revert.
Honors IMP-09 dynamic_cols / fr_default gate.
"""
trace = {"salvage_attempted": False, "salvage_passed": False, "salvage_steps": []}
if layout_css.get("dynamic_cols", False) or not layout_css.get("dynamic_rows", False):
trace["salvage_skipped_reason"] = "IMP-09 gate — dynamic_cols/no dynamic_rows; cascade no-op"
return trace
trace["salvage_attempted"] = True
failure_type = initial_failure_type
ci = cascade_inputs
for _ in range(len(_SALVAGE_FAIL_BY_ACTION)):
routing = route_retry_failure(failure_type) or {}
next_action = routing.get("next_proposed_action")
if next_action not in _SALVAGE_FAIL_BY_ACTION:
trace["salvage_terminal_action"] = next_action
trace["salvage_terminal_rationale"] = routing.get("rationale")
return trace
if next_action == "cross_zone_redistribute":
if ci.get("fit_analysis") is None:
plan = {"action": "cross_zone_redistribute", "feasible": False,
"failure_reason": "cascade_inputs.fit_analysis missing — cross_zone_redistribute requires fit_analysis."}
else:
plan = plan_cross_zone_redistribute(
fit_analysis=ci["fit_analysis"], containers=ci.get("containers") or {},
min_margin_px=ci.get("min_margin_px"))
apply_fn = apply_cross_zone_redistribute_css
elif next_action == "glue_compression":
plan = plan_glue_compression(
excess_px=float(ci.get("excess_px") or 0.0),
block_count=int(ci.get("block_count") or 0),
zone_position=str(ci.get("zone_position") or ""))
apply_fn = apply_glue_compression_css
else:
plan = plan_font_step_compression(
current_font_px=float(ci.get("current_font_px") or 0.0),
excess_after_glue_px=float(ci.get("excess_after_glue_px") or ci.get("excess_px") or 0.0),
available_lines=int(ci.get("available_lines") or 0),
chars_per_line=int(ci.get("chars_per_line") or 0),
zone_position=str(ci.get("zone_position") or ""))
apply_fn = apply_font_step_compression_css
css_override = apply_fn(plan) if (plan and plan.get("feasible")) else ""
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
candidate_html, candidate_overflow, passed = None, None, False
if css_override:
base = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=gap_px)
style = f"<style>\n{css_override}\n</style>"
candidate_html = base.replace("</head>", f"{style}\n</head>", 1) if "</head>" in base else style + base
candidate_path.write_text(candidate_html, encoding="utf-8")
candidate_overflow = run_overflow_check(candidate_path)
passed = bool(candidate_overflow.get("passed", False))
step = {"action": next_action, "plan": plan, "passed": passed,
"css_override": css_override or None,
"candidate_path": str(candidate_path.relative_to(PROJECT_ROOT)) if css_override else None}
if passed:
out_path.write_text(candidate_html, encoding="utf-8")
step["post_salvage_overflow"] = candidate_overflow
trace["salvage_steps"].append(step)
trace["salvage_passed"] = True
return trace
step["failure_reason"] = (
(plan.get("failure_reason") if isinstance(plan, dict) else None)
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
or "infeasible or no CSS emitted")
trace["salvage_steps"].append(step)
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
return trace
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,
*, embedded_mode: str = "auto") -> str:
layout_css: dict, gap_px: int = GRID_GAP) -> 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"]),
@@ -2066,7 +1744,6 @@ 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,
)
@@ -2213,53 +1890,7 @@ def run_overflow_check(html_path: Path) -> dict:
});
});
// IMP-15 실행-1 (issue #45) — image_events[] for image_aspect_mismatch detection.
// 하나의 entry per <img> under .slide. natural vs rendered aspect 비교.
// zone_position : closest('.zone') data-zone-position. 없으면 literal "unknown".
const image_events = [];
slide.querySelectorAll('img').forEach((img) => {
const parentZone = img.closest('.zone');
const zonePos = parentZone
? (parentZone.getAttribute('data-zone-position') || 'unknown')
: 'unknown';
const zoneTid = parentZone
? (parentZone.getAttribute('data-template-id') || '?')
: '?';
const imgRect = img.getBoundingClientRect();
const rendered_w = imgRect.width;
const rendered_h = imgRect.height;
const natural_w = img.naturalWidth;
const natural_h = img.naturalHeight;
const natural_ratio = (natural_w > 0 && natural_h > 0)
? (natural_w / natural_h)
: null;
const rendered_ratio = (rendered_w > 0 && rendered_h > 0)
? (rendered_w / rendered_h)
: null;
const delta = (natural_ratio !== null && rendered_ratio !== null)
? (rendered_ratio - natural_ratio)
: null;
image_events.push({
src: img.getAttribute('src') || '',
zone_position: zonePos,
zone_template_id: zoneTid,
natural_w: natural_w,
natural_h: natural_h,
rendered_w: Math.round(rendered_w),
rendered_h: Math.round(rendered_h),
natural_ratio: natural_ratio,
rendered_ratio: rendered_ratio,
delta: delta,
bbox: {
x: Math.round(imgRect.left - slideRect.left),
y: Math.round(imgRect.top - slideRect.top),
w: Math.round(rendered_w),
h: Math.round(rendered_h),
},
});
});
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events };
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px };
""")
screenshot_path = html_path.parent / "preview.png"
@@ -2299,25 +1930,6 @@ def run_overflow_check(html_path: Path) -> dict:
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
)
# IMP-15 실행-1 (issue #45) — image_aspect_mismatch aggregation.
# |natural_ratio - rendered_ratio| > IMAGE_ASPECT_DELTA_TOL ⇒ fail_reason append.
# Entries with null ratio (image not loaded / natural dims = 0) are skipped (no false positive).
for ev in result.get("image_events", []):
delta = ev.get("delta")
if delta is None:
continue
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
n_ratio = ev.get("natural_ratio")
r_ratio = ev.get("rendered_ratio")
src = ev.get("src", "")
pos = ev.get("zone_position", "unknown")
tid = ev.get("zone_template_id", "?")
fail_reasons.append(
f"image aspect mismatch in zone--{pos}: "
f"natural={n_ratio:.3f} rendered={r_ratio:.3f} delta={delta:+.3f} "
f"(template={tid}, tol={IMAGE_ASPECT_DELTA_TOL}, src={src})"
)
result["passed"] = len(fail_reasons) == 0
result["fail_reasons"] = fail_reasons
return result
@@ -3771,14 +3383,7 @@ def run_phase_z2_mvp1(
dz["ratio"] = geo["zone_height_ratio"]
dz["width_px"] = geo["zone_width_px"]
dz["width_ratio"] = geo["zone_width_ratio"]
if layout_css["dynamic_rows"] and layout_css.get("dynamic_cols"):
print(
f" zones : 2-D heights {layout_css['heights_px']} px, "
f"widths {layout_css['widths_px']} px, "
f"ratios {layout_css['ratios']}, "
f"width_ratios {layout_css['width_ratios']}"
)
elif layout_css["dynamic_rows"]:
if layout_css["dynamic_rows"]:
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
elif layout_css.get("dynamic_cols"):
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
@@ -4145,24 +3750,6 @@ def run_phase_z2_mvp1(
"delegated_to": delegated,
})
# IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog
# frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px).
# None when contract unregistered (frontend tolerates undefined).
# Single get_contract lookup binds both catalog_registered and min_height_px.
v4_all_judgments_list = []
for c in v4_all_for_unit:
_contract = get_contract(c.template_id)
v4_all_judgments_list.append({
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"v4_rank": c.v4_rank,
"confidence": c.confidence,
"label": c.label,
"catalog_registered": _contract is not None,
"min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"),
})
application_plan_units.append({
"unit_id": unit_id,
"layout_preset": layout_preset,
@@ -4198,8 +3785,18 @@ def run_phase_z2_mvp1(
# v4_all_judgments 는 reject 포함.
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
# IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered).
"v4_all_judgments": v4_all_judgments_list,
"v4_all_judgments": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"v4_rank": c.v4_rank,
"confidence": c.confidence,
"label": c.label,
"catalog_registered": get_contract(c.template_id) is not None,
}
for c in v4_all_for_unit
],
"application_candidates": app_candidates,
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
# additive fields. None / False / [] when no override CLI used.
@@ -4425,8 +4022,7 @@ def run_phase_z2_mvp1(
note=(
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
"image_aspect_mismatch 검사 추가 (IMP-15 실행-1, issue #45) — image_events[] + fail_reasons. "
"table 검사 부재 (실행-2 잔류) — Step 14 ⚠ partial (table only)."
"image / table 검사 부재 — Step 14 ⚠ partial."
),
)
@@ -4503,85 +4099,19 @@ def run_phase_z2_mvp1(
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
enrich_retry_trace_with_failure_classification(retry_trace)
# 11.7 IMP-12 u9 — Step 17 deterministic salvage cascade.
# Triggered on donor_slack_insufficient / no_donor_candidates: cross_zone_redistribute
# → glue_compression → font_step_compression (terminal → layout_adjust/frame_reselect).
_ft = (retry_trace.get("failure_classification") or {}).get("failure_type")
if _ft in {"donor_slack_insufficient", "no_donor_candidates"}:
_plan = retry_trace.get("plan") or {}
_tpos = _plan.get("target_zone_position")
_tdz = next((dz for dz in debug_zones if dz.get("position") == _tpos), {}) or {}
_excess = float(_plan.get("target_excess_y") or 0.0)
# Synthesize FitAnalysis from debug_zones + per-zone overflow so cross_zone_redistribute
# can compute feasibility against real Phase Z geometry (Phase Q's calculate_fit is
# not invoked in Phase Z — see comp_debug v4_fallback_summary policy at 2839-2843).
# Each position becomes a "role"; all share conceptual zone "slide_body" so
# fit_verifier.redistribute trades shortfalls between them. shortfall_px is signed:
# scrollHeight - clientHeight > 0 = deficit, < 0 = surplus (matches redistribute()).
from src.fit_verifier import FitAnalysis, RoleFit
_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
_fa_roles, _fa_containers = {}, {}
for _dz in debug_zones:
_pos = _dz.get("position")
if not _pos:
continue
_alloc = float(_dz.get("height_px") or 0.0)
_zm = _zof.get(_pos) or {}
_ch = float(_zm.get("clientHeight") or _alloc)
_sh = float(_zm.get("scrollHeight") or _ch)
_fa_roles[_pos] = RoleFit(role=_pos, allocated_px=_alloc, shortfall_px=_sh - _ch)
_fa_containers[_pos] = {"zone": "slide_body", "height_px": int(_alloc)}
_salvage_trace = _attempt_salvage_chain(
run_dir=run_dir, out_path=out_path,
slide_title=slide_title, slide_footer=slide_footer,
zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
cascade_inputs={
"fit_analysis": FitAnalysis(roles=_fa_roles),
"containers": _fa_containers,
"min_margin_px": None,
"excess_px": _excess, "excess_after_glue_px": _excess,
"block_count": len((_tdz.get("placement_trace") or {}).get("internal_regions") or []) or 1,
"zone_position": _tpos or "",
"current_font_px": float(_tdz.get("font_size_px") or 0.0),
"available_lines": int(_tdz.get("available_lines") or 0),
"chars_per_line": int(_tdz.get("chars_per_line") or 0),
},
initial_failure_type=_ft, gap_px=GRID_GAP,
)
retry_trace.update(_salvage_trace)
if _salvage_trace.get("salvage_passed"):
overflow = (_salvage_trace["salvage_steps"][-1].get("post_salvage_overflow")) or overflow
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
router_decision = route_fit_classification(fit_classification)
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
router_decision["frame_reselect_fallback_status"] = (
"pre_render_rank_2_3_fallback_implemented; "
"post_render visual-fail rerender remains routed through existing action trace"
)
# Refresh failure_classification / next_action_proposal so Step 18 / Step 19
# do not surface the pre-salvage donor_slack_insufficient / no_donor_candidates
# state. classify_retry_failure short-circuits on salvage_passed=True → both
# fields become None (no failure to classify, no escalation pending).
enrich_retry_trace_with_failure_classification(retry_trace)
# ─── Step 17: Implemented Action (retry) ───
_write_step_artifact(
run_dir, 17, "retry_trace",
data=retry_trace,
step_status=(
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
else "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
"failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
else "done" if retry_trace.get("retry_passed")
else "skipped"
),
pipeline_path_connected=True,
inputs=["step16_router_decision.json"],
outputs=["step17_retry_trace.json"],
note=(
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
"(cross_zone_redistribute → glue_compression → font_step_compression). "
"Terminal actions (layout_adjust / frame_reselect / details_popup_escalation) still MISSING."
),
note="A3 — zone_ratio_retry action only. 다른 actions (layout_adjust / frame_internal_fit 등) 미구현 — Step 17 ⚠ partial.",
)
# ─── Step 18: Failure Classification (A4-1) ───
+13 -207
View File
@@ -162,55 +162,35 @@ def plan_zone_ratio_retry(
),
}
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
if aggregate_slack_available < target_added_px:
# A3 minimal : single primary donor (multi-donor 는 future)
primary_donor = donor_candidates[0]
if primary_donor["slack"] < target_added_px:
return {
**base_plan,
"feasible": False,
"donor_zone_position": donor_candidates[0]["position"],
"donor_max_slack": donor_candidates[0]["slack"],
"donor_zone_position": primary_donor["position"],
"donor_max_slack": primary_donor["slack"],
"donor_reduced_px": 0,
"donors_used": [],
"aggregate_slack_used": 0,
"aggregate_slack_available": aggregate_slack_available,
"zones_after": dict(zones_before),
"failure_reason": (
f"primary donor '{donor_candidates[0]['position']}' slack "
f"{donor_candidates[0]['slack']}px (aggregate "
f"{aggregate_slack_available}px across {len(donor_candidates)} "
f"candidate(s)) < target_added_px {target_added_px}px "
f"(excess_y {target_excess_y} + safety_margin {safety_margin_px})."
f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px "
f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + "
f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis."
),
}
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
# feasible
zones_after = dict(zones_before)
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
donors_used: list[dict] = []
remaining = target_added_px
for donor in donor_candidates:
if remaining <= 0:
break
take = min(donor["slack"], remaining)
zones_after[donor["position"]] = zones_before[donor["position"]] - take
donors_used.append({
"position": donor["position"],
"reduced_px": take,
"slack_before": donor["slack"],
"slack_after": donor["slack"] - take,
})
remaining -= take
zones_after[primary_donor["position"]] = (
zones_before[primary_donor["position"]] - target_added_px
)
primary_donor = donors_used[0]
return {
**base_plan,
"feasible": True,
"donor_zone_position": primary_donor["position"],
"donor_reduced_px": primary_donor["reduced_px"],
"donors_used": donors_used,
"aggregate_slack_used": target_added_px,
"aggregate_slack_available": aggregate_slack_available,
"donor_reduced_px": target_added_px,
"zones_after": zones_after,
}
@@ -233,177 +213,3 @@ def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dic
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
new_layout_css["raw_zone_layout"]["retry_applied"] = True
return new_layout_css
# ──────────────────────────────────────
# IMP-12 u4 : cross_zone_redistribute (Step 17 salvage cascade — stage 1)
# Wraps src.fit_verifier.redistribute in the Step-17 plan signature so the
# failure-router cascade (donor_slack_insufficient → cross_zone_redistribute)
# can drive it deterministically. Plan-only — no rerender / no final.html
# mutation. Side-effect-free (operates on deepcopy of fit_analysis).
# ──────────────────────────────────────
def plan_cross_zone_redistribute(
*,
fit_analysis,
containers: dict,
min_margin_px: float | None = None,
) -> dict:
"""Cross-zone (intra-zone role-to-role) redistribute plan.
Plan-only — no rerender / no final.html mutation. Side-effect-free
(operates on deepcopy of fit_analysis).
"""
from copy import deepcopy
from src.fit_verifier import redistribute as _fv_redistribute
role_heights_before = {
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
}
base_plan = {
"action": "cross_zone_redistribute",
"role_heights_before": role_heights_before,
}
if not role_heights_before:
return {**base_plan, "feasible": False, "role_heights_after": {},
"can_redistribute": False,
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
redistribution = dict(result.redistribution or {})
can_redistribute = bool(result.can_redistribute)
if not can_redistribute or not redistribution:
return {
**base_plan,
"feasible": False,
"role_heights_after": redistribution or dict(role_heights_before),
"can_redistribute": can_redistribute,
"failure_reason": (
"fit_verifier.redistribute can_redistribute=False — single-role zone(s) "
"or surplus insufficient to cover deficit within envelope."
),
}
return {**base_plan, "feasible": True, "role_heights_after": redistribution,
"can_redistribute": True}
def apply_cross_zone_redistribute_css(plan: dict) -> str:
"""Emit scoped role-height CSS overrides — [data-role="<role>"] only.
Honors feedback_phase_z_spacing_direction: no :root / body / .slide / .zone selectors.
"""
if not plan.get("feasible"):
return ""
role_heights_after = plan.get("role_heights_after") or {}
role_heights_before = plan.get("role_heights_before") or {}
rules: list[str] = []
for role, new_height in role_heights_after.items():
before = role_heights_before.get(role)
if before is None or abs(float(before) - float(new_height)) < 0.5:
continue
new_h_int = int(round(float(new_height)))
rules.append(
f'[data-role="{role}"] {{ height: {new_h_int}px; min-height: {new_h_int}px; }}'
)
return "\n".join(rules)
# IMP-12 u5 : glue_compression — Step 17 salvage cascade (stage 2).
# Wraps space_allocator.compute_glue_css_overrides in the Step-17 plan signature.
# Frame-scoped: overrides emitted only under [data-zone-position="<pos>"]
# (feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
def plan_glue_compression(
*, excess_px: float, block_count: int, zone_position: str,
) -> dict:
"""Glue compression plan (frame-scoped). feasible only when envelope absorbs excess."""
from src.space_allocator import (
calculate_glue_absorption, compute_glue_css_overrides,
)
base = {"action": "glue_compression", "zone_position": zone_position,
"excess_px": float(excess_px), "block_count": int(block_count)}
if excess_px <= 0:
return {**base, "feasible": False, "overrides": {}, "absorption_max_px": 0.0,
"failure_reason": "excess_px <= 0 — no compression needed."}
absorption_max = float(calculate_glue_absorption(block_count))
overrides = compute_glue_css_overrides(excess_px, block_count) or {}
if excess_px > absorption_max:
return {**base, "feasible": False, "overrides": overrides,
"absorption_max_px": absorption_max,
"failure_reason": (
f"glue envelope insufficient — excess_px {excess_px:.1f} > "
f"max absorption {absorption_max:.1f}px "
f"(block_count={block_count}, SPACING_GLUE shrink budget)."
)}
return {**base, "feasible": True, "overrides": overrides,
"absorption_max_px": absorption_max}
def apply_glue_compression_css(plan: dict) -> str:
"""Emit zone-scoped glue CSS — wrapped in [data-zone-position="<pos>"] only."""
if not plan.get("feasible"):
return ""
zone_position = plan.get("zone_position")
overrides = plan.get("overrides") or {}
if not zone_position or not overrides:
return ""
var_lines = "\n".join(f" {k}: {v};" for k, v in overrides.items())
return f'[data-zone-position="{zone_position}"] {{\n{var_lines}\n}}'
# IMP-12 u6 : font_step_compression — Step 17 salvage cascade (stage 3).
# Wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
# Zone-scoped: only [data-zone-position="<pos>"] (no :root/body/.slide/.zone).
def plan_font_step_compression(
*, current_font_px: float, excess_after_glue_px: float,
available_lines: int, chars_per_line: int, zone_position: str,
) -> dict:
"""Font-step compression plan (zone-scoped). feasible only when FONT_SIZE_STEPS
contains a size whose line-height savings cover excess_after_glue_px. Missing
text_metrics yields feasible=False (cascade routes onward to layout_adjust)."""
from src.space_allocator import FONT_SIZE_STEPS, find_fitting_font_size
floor = float(FONT_SIZE_STEPS[-1])
base = {"action": "font_step_compression", "zone_position": zone_position,
"current_font_px": float(current_font_px),
"excess_after_glue_px": float(excess_after_glue_px),
"available_lines": int(available_lines or 0),
"chars_per_line": int(chars_per_line or 0),
"font_floor_px": floor}
if excess_after_glue_px <= 0:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": "excess_after_glue_px <= 0 — no font compression needed."}
if not available_lines or available_lines <= 0 or not chars_per_line or chars_per_line <= 0:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": "text_metrics missing — available_lines/chars_per_line required."}
if current_font_px <= floor:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": (
f"current_font_px {current_font_px:.1f} already at FONT_SIZE_STEPS floor {floor:.1f}px.")}
target = find_fitting_font_size(
current_font_px=float(current_font_px),
excess_after_glue_px=float(excess_after_glue_px),
available_lines=int(available_lines), chars_per_line=int(chars_per_line))
if target is None:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": (
f"font_step floor — {floor:.1f}px cannot absorb "
f"excess_after_glue_px={excess_after_glue_px:.1f}px "
f"(available_lines={available_lines}, FONT_SIZE_STEPS exhausted).")}
return {**base, "feasible": True, "target_font_px": float(target)}
def apply_font_step_compression_css(plan: dict) -> str:
"""Emit zone-scoped font-size CSS — [data-zone-position="<pos>"] only."""
if not plan.get("feasible"):
return ""
zone_position = plan.get("zone_position")
target_font_px = plan.get("target_font_px")
if not zone_position or target_font_px is None:
return ""
return (f'[data-zone-position="{zone_position}"] {{\n'
f" font-size: {float(target_font_px):.1f}px;\n}}")
+1 -7
View File
@@ -56,7 +56,7 @@ ACTION_RATIONALE: dict[str, str] = {
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
}
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준)
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
@@ -65,12 +65,6 @@ ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
# IMP-12 u7 (2026-05-18): cascade-only salvage actions (no ACTION_BY_CATEGORY row;
# surfaced via NEXT_ACTION_BY_FAILURE in phase_z2_failure_router). plan/apply pairs
# implemented in phase_z2_retry; pipeline orchestrator wiring lands in u8/u9.
"cross_zone_redistribute": "IMPLEMENTED", # u4 phase_z2_retry.plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
"glue_compression": "IMPLEMENTED", # u5 phase_z2_retry.plan_glue_compression + apply_glue_compression_css
"font_step_compression": "IMPLEMENTED", # u6 phase_z2_retry.plan_font_step_compression + apply_font_step_compression_css
}
+134 -19
View File
@@ -84,7 +84,7 @@ process_product_two_way:
strict: 2 # F29 frame = 2 visual columns. ≠2 → fallback.
overflow_policy: abort_or_review
# transform-block + 3 sections 의 breathing 위해 345 (이전 hardcoded 동일).
# transform-block + 3 sections 의 breathing 위해 345 (원래 hardcoded 동일).
visual_hints:
min_height_px: 345
@@ -98,16 +98,19 @@ process_product_two_way:
# NEW (SPEC v1 §3) : Frame Slot (Layer B) 선언.
# 2 column × 3 section = 6 cell, but Frame Slot 단위 = column.
# partial_target_path = path 식별자 만 (값). marker attribute 결정은 별 axis.
# 2026-05-14 — cardinality dynamic 화. 사용자 룰 : frame 이 콘텐츠 cardinality 에
# 맞춰 보완·수정. 03 (3 cards) / 04-2 (4 cards) / 미래 N cards 모두 동일 frame 으로
# 자동 대응. strict 제거 → null = 동적 max 미정.
sub_zones:
- id: process_column
role: main_text
accepts: [text_block, transform_table]
cardinality: { strict: 3 } # 3 sections per column
cardinality: { strict: null }
partial_target_path: ".f29b__grid .f29b__cell--left"
- id: product_column
role: main_text
accepts: [text_block] # product 쪽은 transform 안 받음 (현재 frame 의 시각 구분)
cardinality: { strict: 3 }
accepts: [text_block]
cardinality: { strict: null }
partial_target_path: ".f29b__grid .f29b__cell--right"
payload:
@@ -115,7 +118,8 @@ process_product_two_way:
source: section.title
builder: process_product_pair
builder_options:
pad_sections_to: 3 # 각 column.sections 길이 = 3 (legacy 와 동일)
# pad_sections_to null → builder 가 pad / truncate 안 함. 콘텐츠 원본 cardinality 보존.
pad_sections_to: null
columns:
- title_to: banner_left
body_to: process
@@ -397,11 +401,14 @@ bim_dx_comparison_table:
frame_id: 1171281195
family: table
source_shape: top_bullets
# NOTE (Codex round 43 §F1-c) : top-level `cardinality.strict: 2` = *column 수*
# (col_a / col_b). data row 수 는 별 — `sub_zones.rows.cardinality` 의 `{min:1, max:12}`.
# 2026-05-14 — source_shape 변경 : top_bullets → h3_subsections.
# 사용자 룰 (frame 보완·수정) : ranking 에 나온 frame 은 mdx 콘텐츠 구조에 맞게
# frame 의 builder/partial 보완. 04-2 의 통합 콘텐츠 (### 2.1 정책 + ### 2.2 조직) 가
# 2 column 으로 자연 매핑되도록 source_shape 변경 + builder = process_product_pair 재사용.
# frame 의 visual identity (갈색 그라데이션 / zebra / table 시각) 는 partial 에서 유지.
source_shape: h3_subsections
cardinality:
strict: 2 # 2 columns (col_a / col_b) — NOT row count
strict: 2 # 2 h3 subsections (col_a / col_b)
overflow_policy: abort_or_review
role_order:
@@ -440,17 +447,81 @@ bim_dx_comparison_table:
payload:
title:
source: section.title
# NEW builder `compare_table_2col` (phase_z2_mapper.py) +
# NEW parser `compare_row_2col_item` (ITEM_PARSERS).
builder: compare_table_2col
# 2026-05-14 — builder 변경 : compare_table_2col → process_product_pair 재사용.
# 사용자 룰 (frame 보완) : 04-2 같은 통합 콘텐츠 (### 2 h3 + 각 N top_bullets) 가
# 2 column N items 로 자연 매핑. frame 의 갈색 visual + zebra 는 partial 에서 유지.
# col_a_label_default / col_b_label_default 제거 — h3 title 그대로 사용 (BIM/DX 강제 없음).
builder: process_product_pair
builder_options:
item_parser: compare_row_2col_item # NEW parser — top_bullet → {label, col_a, col_b}
col_a_label_default: "BIM" # F1-a (Codex round 43) — explicit default
col_b_label_default: "DX" # F1-a — explicit default
strip_col_prefix_aliases: # F1-b (Codex round 43) — narrow alias 만 strip
- "BIM"
- "DX"
max_rows: 12 # typical 4-8, overflow 보호
pad_sections_to: null # dynamic — 각 column N items 그대로
columns:
- title_to: col_a_label
body_to: col_a_body
# 2026-05-15 — 03-2 의 markdown table (AS-IS/TO-BE) 보존. column_plain
# 은 transforms 무시 → 사용자 본 "AS-IS/TO-BE 사라짐". with_transform
# 으로 변경하면 첫 top-bullet 안 markdown table 을 transforms 로 추출.
# 04-2 같은 transforms 없는 콘텐츠는 fallback 으로 일반 text_lines.
body_parser: column_with_transform
- title_to: col_b_label
body_to: col_b_body
body_parser: column_with_transform
app_sw_package_vs_solution:
# 2026-05-15 — catalog 등록 (frame 23). 03-2 의 V4 rank 3 (reject 0.647).
# 사용자 룰 = rank 마다 frame visual 은 달라도 mdx 콘텐츠는 동일 / 무손실.
# builder + parser = bim_dx_comparison_table 와 동일 (process_product_pair +
# column_with_transform) — markdown table (AS-IS/TO-BE) 보존. partial 만 frame 23
# 의 visual identity (label col + 2 data cols, teal/orange header, zebra rows).
template_id: app_sw_package_vs_solution
frame_id: 1171281203
family: table
source_shape: h3_subsections
cardinality:
strict: 2
overflow_policy: abort_or_review
role_order:
- col_a
- col_b
visual_hints:
min_height_px: 350
accepted_content_types:
- text_block
sub_zones:
- id: col_a_header
role: header
accepts: [text_block]
cardinality: { strict: 1 }
partial_target_path: ".f23b__th--a"
- id: col_b_header
role: header
accepts: [text_block]
cardinality: { strict: 1 }
partial_target_path: ".f23b__th--b"
- id: rows
role: data
accepts: [text_block]
cardinality: { min: 1, max: 12 }
partial_target_path: ".f23b__rows"
payload:
title:
source: section.title
builder: process_product_pair
builder_options:
pad_sections_to: null
columns:
- title_to: col_a_label
body_to: col_a_body
body_parser: column_with_transform
- title_to: col_b_label
body_to: col_b_body
body_parser: column_with_transform
dx_sw_necessity_three_perspectives:
@@ -727,3 +798,47 @@ bim_current_problems_paired:
body_key_pattern: "row_{r}_{side}_body"
rows: 4
sides: [left, right]
# 2026-05-14 신규 — V4 04-1 top conf=0.722 rank-1 매칭 frame. ideal 5 / min 4 / max 6.
# Frame 9 visual : stacked pills (계단 배치, 5색), 좌측 vertical label, 타이틀 bar.
# 보완 (사용자 룰) : pill 안 nested body slot 추가 (1 라인 frame 9 → multi-line pill)
# 으로 04-1 의 5 cards × (인용구 + 3 bullets) 콘텐츠 보존.
pre_construction_model_info_stacked:
template_id: pre_construction_model_info_stacked
frame_id: 1171281180
family: stacked_list
source_shape: top_bullets
cardinality:
strict: null # dynamic (ideal 5 / min 4 / max 6 per Figma analysis)
overflow_policy: abort_or_review
# 2026-05-14 — frame 9 의 catalog default = frame visual 자체 (모든 mdx 공통).
# pill horizontal 변환 시 frame visual 보완값 = 250. 슬라이드 단위 zone 크기 변경은
# catalog 가 아닌 run-time override (zone_geometry 등) 로 처리해야 함.
visual_hints:
min_height_px: 250
accepted_content_types:
- text_block
# dynamic sub_zones (N pills) — N = 콘텐츠 cardinality 그대로.
# partial_target_path = nth-child selector pattern (frontend / future override 용).
sub_zones:
- id: pill_dynamic
role: main_text
accepts: [text_block]
cardinality: { strict: null }
partial_target_path: ".f9b__pill-rows .f9b__pill:nth-child(N)"
payload:
title:
source: section.title
builder: quadrant_flat_slots # 재사용 — pad_to/truncate_at null 시 dynamic
builder_options:
item_parser: quadrant_item
pad_to: null # null = dynamic (콘텐츠 N 그대로)
truncate_at: null # null = truncate 안 함
label_key_pattern: "pill_{n}_label"
body_key_pattern: "pill_{n}_body"
@@ -1,53 +1,35 @@
<!-- Phase Z-2 MVP-1.5b frame-derived adapted block.
§17 룰 — Figma 시각 언어 promote, geometry 만 zone-compatible adapt. -->
<!-- Phase Z-2 frame partial — Frame 18 (1171281195) bim_dx_comparison_table.
2026-05-14 — Figma 원본 visual (color/structure/badge/typography) 정확 반영 후 보완.
사용자 catch : 이전 partial 이 figma 원본과 색감/구조 달랐음. 본 partial 재작성.
visual = 3-col compare-row layout (col-left brown / col-badge pill green-brown / col-right green).
보완 (사용자 룰) : col_a/col_b body = N items 그대로 (04-2 통합 콘텐츠 정책 4 + 조직 4
같은 경우도 자연 매핑). badge = row 번호 자동 (figma 원본의 가운데 axis label 자리). -->
{#
─────────────────────────────────────────────────────────────────────────────
Visual Provenance — figma_to_html_agent/blocks/1171281195/ (frame 18)
Visual Provenance — figma_to_html_agent/blocks/1171281195/index.html
─────────────────────────────────────────────────────────────────────────────
Frame 18 = "BIM 과 DX 의 이해" (table / compare-rows). 2개 column header (BIM vs DX) +
중앙 category column + N category rows (범위 / S/W / 프로세스 / 성과물 / 활용 /
확장성 / 수행개념 / 주체 등). compare 관점의 다면 비교 표 디자인.
PROMOTED (Figma 1:1, scale 1280/1868 = 0.68524) :
- title gradient : linear-gradient(rgb(204,82,0), rgb(136,55,0))
- subheader bar : pill (border-radius: 50px), 좌=green / 우=brown gradient
- col-left color : #5c3714 (brown family)
- col-right color : #285b4a (deep green family)
- col-badge : 둥근 사각형 (radius 10px), 그라데이션 green↔brown, 흰 글자
- bullets : list-style disc inside (filled bullets)
- typography weight : 700 전체
본 partial = Track A frame 4 (Codex round 41 accepted, V4 UAI tier). NEW builder
`compare_table_2col` + NEW parser `compare_row_2col_item` 도입.
ADAPTED (Phase Z compact / dynamic N rows) :
- figma 원본 1868×1908 px → 1200×590 slide-body scale
- figma 12 hardcoded rows → dynamic N (col_a_body.sections count = 콘텐츠 N)
- figma badge text (axis label) → row 번호 (① ② ③ ...) — 04-2 같은 통합 콘텐츠는
축 라벨 없음, row 번호로 대체. 정책[i] vs 조직[i] 한 row pair.
- figma 결론 영역 (arrow + box) → omit (slide-footer 가 별 area)
3-layer architecture (matrix §0) :
- V4 = matching authority — V4 ranked this frame use_as_is for 01-2.
- figma_to_html = source/evidence — analysis/texts/index.html/flat/assets full.
- Phase Z = runtime orchestration — 본 commit 추가 runtime 자원.
PROMOTED — CSS :
- title gradient (#000 → #883700, F13/F14/F12/F11 zone-title family)
- header row : col_a/col_b background tint + bold typography
- row 별 alternating background (zebra) — readability ↑
- cell border : subtle divider (#A5BBB4 family — F11 와 통일)
NOT PROMOTED (P1 case-by-case, compact zone fit) :
- Figma 의 column header 의 *raster icon* (있는 경우) — compact 에서 효과 ↓
- 다양한 색 강조 / 한자 deco (Figma source 보존, 미 promote)
ADAPTED :
- Figma 표 absolute positioning + zoom → Phase Z flex/grid (3-column table)
- typography → token-fixed (zone-title / sub-title / caption / body)
- row 별 height auto (content-fit)
─────────────────────────────────────────────────────────────────────────────
min_height_px derivation :
title 30 + header 30 + 6 data rows × 35 (compact) + padding 30 = 300
+ safety buffer (longer cell content) 50 = **350** (F14 와 동등 class).
Confirm via smoke + R3 artifact + V4 use_as_is target sample (MDX 01-2 미 trigger
in MDX 03 regression, 단 future batch verification 가능).
─────────────────────────────────────────────────────────────────────────────
slots :
- title : section.title
- col_a_label : 좌 column header (예: "BIM")
- col_b_label : 우 column header (예: "DX")
- rows : list[{label, col_a, col_b}] — N category rows
─────────────────────────────────────────────────────────────────────────────
4-class failure taxonomy :
- class 1 adapter readiness : NEW builder `compare_table_2col` + NEW parser
`compare_row_2col_item` 등록 필수 (PAYLOAD_BUILDERS / ITEM_PARSERS). 본 commit.
- class 2 content-fit : row 수 max 12 (builder limit). cell content 한 줄 한계
— 긴 문장 시 truncate 또는 wrap (CSS overflow-hidden).
slots (payload via process_product_pair builder) :
- title : section.title
- col_a_label : 좌 column header (예: "정책 및 발주 체계")
- col_b_label : 우 column header (예: "조직 및 수행 역량")
- col_a_body.sections[] : 좌 column items, 각 {title, text_lines}
- col_b_body.sections[] : 우 column items
#}
<style>
@@ -57,90 +39,246 @@ slots :
gap: 6px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
position: relative;
/* zone aspect 기반 내부 회전 (사용자 lock 2026-05-15) */
container-type: size;
container-name: f18b-root;
}
/* title — PROMOTED orange-brown gradient (figma line 41-43) */
.f18b__title {
font-size: var(--font-zone-title);
font-weight: 700;
line-height: var(--lh-zone-title);
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
background-image: linear-gradient(rgb(204,82,0), rgb(136,55,0));
-webkit-background-clip: text; background-clip: text;
color: transparent;
text-shadow: 0 0 2px rgba(50,44,30,0.3);
flex-shrink: 0;
filter: drop-shadow(0 0 3px rgba(50,44,30,0.4));
}
/* compare table — 3-column (category / col_a / col_b) */
.f18b__table {
display: grid;
grid-template-columns: 1fr 2fr 2fr;
gap: 0;
flex: 1 1 auto;
min-height: 0;
border: 1px solid #A5BBB4;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
/* header row */
.f18b__header {
display: contents;
}
.f18b__header-cell {
background: linear-gradient(180deg, rgba(50,31,9,0.85) 0%, rgba(50,31,9,0.95) 100%);
/* subheader pill — PROMOTED green-brown gradient (figma line 50) */
.f18b__subheader {
flex-shrink: 0;
height: 30px;
border-radius: 50px;
background: linear-gradient(270deg,
rgb(40,91,74) 0%,
rgba(40,91,74,0.8) 30%,
rgba(74,64,38,0.8) 70%,
rgb(74,64,38) 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
color: #fff;
font-weight: 700;
font-size: var(--font-caption);
line-height: 1.2;
padding: 6px 8px;
text-align: center;
border-bottom: 2px solid #A5BBB4;
}
.f18b__header-cell--category {
background: linear-gradient(180deg, rgba(120,90,40,0.85) 0%, rgba(120,90,40,0.95) 100%);
font-style: italic;
font-size: var(--font-sub-title);
line-height: 1;
}
/* data rows */
/* compare rows container — 넓찍 spacing (사용자 lock 2026-05-14). */
.f18b__rows {
display: contents;
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 8px 0;
}
.f18b__cell {
padding: 5px 8px;
font-size: var(--font-body);
line-height: var(--lh-body);
border-bottom: 1px solid rgba(165,187,180,0.4);
color: #1a1a1a;
overflow: hidden;
/* each row — 3-col flex */
.f18b__row {
display: flex;
align-items: stretch;
gap: 8px;
min-height: 0;
}
.f18b__cell--category {
background: rgba(255,250,240,0.7);
/* 회전 비활성 — 사용자 lock 2026-05-15 : 03-2 는 2 col compare 유지. */
/* col-left : 우측 정렬, brown color (figma line 71-77) */
.f18b__col-left {
flex: 1;
color: #5c3714;
font-weight: 700;
color: #5a4b2e; /* PROMOTED — brown family */
text-align: right;
line-height: 1.25;
padding: 4px 8px;
display: flex;
flex-direction: column;
/* 2026-05-15 — center → flex-start. 각 line 위쪽 정렬 (사용자 lock). */
justify-content: flex-start;
}
/* col-right : 좌측 정렬, green color (figma line 89-95) */
.f18b__col-right {
flex: 1;
color: #285b4a;
font-weight: 700;
text-align: left;
line-height: 1.25;
padding: 4px 8px;
display: flex;
flex-direction: column;
/* 2026-05-15 — center → flex-start. 각 line 위쪽 정렬 (사용자 lock). */
justify-content: flex-start;
}
/* col-badge : 가운데 둥근 사각형, green-brown gradient (figma line 78-84) */
.f18b__col-badge {
flex: none;
width: 48px;
border-radius: 10px;
background: linear-gradient(270deg,
rgba(40,91,74,0.8) 0%,
rgba(40,91,74,0.64) 30%,
rgba(74,64,38,0.64) 70%,
rgba(74,64,38,0.8) 100%);
color: #fff;
font-weight: 700;
font-size: var(--font-sub-title);
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
}
/* item title — bold heading per row */
.f18b__item-title {
font-size: var(--font-body);
font-weight: 900;
margin-bottom: 2px;
}
/* item body lines */
.f18b__item-body {
font-size: var(--font-caption);
line-height: 1.35;
font-weight: 500;
}
.f18b__item-body .text-line {
margin: 0;
}
/* bullets — figma disc inside (line 96-97) */
.f18b__col-left .f18b__item-body .text-line {
list-style-position: inside;
}
.f18b__col-left .f18b__item-body .text-line::before {
content: "• ";
margin-right: 2px;
}
.f18b__col-right .f18b__item-body .text-line::before {
content: "• ";
margin-right: 2px;
}
/* 2026-05-15 — AS-IS/TO-BE transforms mini table (콘텐츠 무손실 + 내용 기반 폭).
사용자 lock : 표 전체는 cell 의 가용 폭 강제 X. 내용에 기반한 width
(display: table = 각 column max content). 표는 우측 정렬 (margin-left: auto).
cell 안 텍스트는 center. 여백 적게. */
.f18b__transforms {
display: table;
margin-left: auto;
margin-right: 0;
border-collapse: separate;
border-spacing: 6px 1px;
font-size: var(--font-caption);
line-height: 1.3;
}
.f18b__transforms-head,
.f18b__transforms-row { display: table-row; }
.f18b__transforms-head > * {
display: table-cell;
text-align: center;
font-weight: 900;
font-size: 0.9em;
color: #6b4423;
padding: 0 4px 1px;
border-bottom: 1px solid rgba(107,68,35,0.3);
}
/* zebra striping (visual readability) — odd rows alternate */
.f18b__row:nth-child(odd) .f18b__cell:not(.f18b__cell--category) {
background: rgba(248,250,252,0.6);
.f18b__transforms-cell,
.f18b__transforms-arrow {
display: table-cell;
text-align: center;
padding: 1px 6px;
vertical-align: middle;
}
.f18b__transforms-cell { color: #1a1a1a; font-weight: 500; }
.f18b__transforms-cell--to { color: #6b4423; font-weight: 700; }
.f18b__transforms-arrow { font-weight: 700; color: #9c6f3f; }
</style>
<div class="f18b" data-frame-id="1171281195" data-template-id="bim_dx_comparison_table">
<div class="f18b__title">{{ slot_payload.title }}</div>
<div class="f18b__table">
<div class="f18b__header">
<div class="f18b__header-cell f18b__header-cell--category">구분</div>
<div class="f18b__header-cell">{{ slot_payload.col_a_label | safe }}</div>
<div class="f18b__header-cell">{{ slot_payload.col_b_label | safe }}</div>
</div>
<div class="f18b__rows">
{% for row in slot_payload.rows %}
<div class="f18b__row" style="display:contents;">
<div class="f18b__cell f18b__cell--category">{{ row.label | safe }}</div>
<div class="f18b__cell f18b__cell--col-a">{{ row.col_a | safe }}</div>
<div class="f18b__cell f18b__cell--col-b">{{ row.col_b | safe }}</div>
{# subheader pill : col_a_label / col_b_label (figma BIM/DX 자리). "vs" 제거. #}
<div class="f18b__subheader">
<span>{{ slot_payload.col_a_label | safe }}</span>
<span>{{ slot_payload.col_b_label | safe }}</span>
</div>
{# compare rows : col_a_body / col_b_body 의 sections 를 row 별로 짝지어 표시.
row 개수 = max(col_a sections, col_b sections). 부족한 쪽은 빈 div.
badge = row 번호 (figma 의 가운데 axis label 자리 — 04-2 같은 통합 콘텐츠는
축 라벨 없으므로 번호로 대체). #}
<div class="f18b__rows">
{% set a_secs = slot_payload.col_a_body.sections or [] %}
{% set b_secs = slot_payload.col_b_body.sections or [] %}
{% set row_count = [a_secs | length, b_secs | length] | max %}
{% for i in range(row_count) %}
{% set a = a_secs[i] if i < (a_secs | length) else none %}
{% set b = b_secs[i] if i < (b_secs | length) else none %}
<div class="f18b__row">
<div class="f18b__col-left" data-frame-slot-id="col_a_body">
{% if a %}
{% if a.title %}<div class="f18b__item-title">{{ a.title | safe }}</div>{% endif %}
<div class="f18b__item-body">
{# 2026-05-15 — 03-2 의 AS-IS/TO-BE markdown table 보존 (mdx 무손실). #}
{% if a.transforms %}
<div class="f18b__transforms">
<div class="f18b__transforms-head">
<span class="f18b__transforms-from">AS-IS</span>
<span></span>
<span class="f18b__transforms-to">TO-BE</span>
</div>
{% for t in a.transforms %}
<div class="f18b__transforms-row">
<div class="f18b__transforms-cell f18b__transforms-cell--from">{{ t.from | safe }}</div>
<div class="f18b__transforms-arrow">➜</div>
<div class="f18b__transforms-cell f18b__transforms-cell--to">{{ t.to | safe }}</div>
</div>
{% endfor %}
</div>
{% elif a.text_lines %}
{% for line in a.text_lines %}<div class="text-line">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
<div class="f18b__col-badge"></div>
<div class="f18b__col-right" data-frame-slot-id="col_b_body">
{% if b %}
{% if b.title %}<div class="f18b__item-title">{{ b.title | safe }}</div>{% endif %}
<div class="f18b__item-body">
{% if b.transforms %}
<div class="f18b__transforms">
<div class="f18b__transforms-head">
<span class="f18b__transforms-from">AS-IS</span>
<span></span>
<span class="f18b__transforms-to">TO-BE</span>
</div>
{% for t in b.transforms %}
<div class="f18b__transforms-row">
<div class="f18b__transforms-cell f18b__transforms-cell--from">{{ t.from | safe }}</div>
<div class="f18b__transforms-arrow">➜</div>
<div class="f18b__transforms-cell f18b__transforms-cell--to">{{ t.to | safe }}</div>
</div>
{% endfor %}
</div>
{% elif b.text_lines %}
{% for line in b.text_lines %}<div class="text-line">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
@@ -5,50 +5,50 @@
Visual Provenance — figma_to_html_agent/blocks/1171281198/ (frame 20)
─────────────────────────────────────────────────────────────────────────────
Frame 20 = "디지털 전환(DX)은 S/W가 필수다" (cards-3-header). Figma 원본 =
상단 dark green banner ("디지털 전환(DX)은 S/W가 필수다" green/white mix) +
좌측 DX circular area + 우측 3 perspective cards (BIM 전면설계 / 디지털
전환 S/W / 고부가가치 산업전환).
상단 dark banner ("디지털 전환(DX)은 S/W가 필수다") + 좌측 DX circular area
+ 우측 3 perspective cards (BIM 전면설계 / 디지털 전환 S/W / 고부가가치 산업전환).
본 partial = Track A frame 5 (Codex round 45 accepted, V4 LE tier strongest).
builder 재사용 = `quadrant_flat_slots` (F11 cards-3-category pattern). 새
builder/parser 0.
본 partial = Figma 1:1 — *우측 3 cards* 만 가져옴.
좌측 DX circle area = 사용자 지시로 제외 (zone fit). 중목차 (zone title)
디자인 = 건드리지 않음. Figma asset PNG 직접 사용 (recolor X) :
card_frame.png (515.579×377.767) — 카드 외곽 stroke/frame
card_header_bg.png (378.435×85.881) — 카드 header 배경
card_bottom_decor.png(515.579×185.746) — 카드 하단 장식
bullet_arrow.png (41.246×39.486) — ▶ 화살표
3-layer architecture (matrix §0) :
- V4 = matching authority — V4 ranked this frame light_edit for 03-1 + 01-1.
- figma_to_html (1171281198) = source/evidence — 386-line index.html + assets/.
- Phase Z = runtime — 본 commit adds catalog + partial + smoke fixture.
PROMOTED — Figma visual identity 1:1 :
- 카드 frame asset (외곽 line + 내부 background)
- header bg asset (상단 colored bar)
- bottom decor asset (하단 그림자 + 장식)
- 카드 title = white fill + 컬러 stroke + dark glow
· card 1 stroke = #1d4d3e (dark green)
· card 2 stroke = #cc5200 (orange)
· card 3 stroke = #1d4d3e (dark green)
· text-shadow = 0 0 4px #322c1e (모든 card 공통)
· paint-order = stroke fill (Figma 렌더 일치)
- bullet = bullet_arrow.png (CSS recolor X)
PROMOTED — CSS :
- 3 column header bg : dark green (`#296B55` family, Figma green theme)
- header text white bold + green accent
- title gradient (#000 → #883700, F13/F14/F12/F11/F18 zone-title family)
- card border + bullet markers (green family)
NOT PROMOTED (zone fit / 사용자 지시) :
- 좌측 DX circular area (multi-image + center text + decor)
- 상단 dark banner (zone title 이 대체)
- hanmaek-label / left-bg / left-decor / left-inner-bg / middle-overlay
NOT PROMOTED (P1 case-by-case, compact zone fit) :
- 상단 dark green banner (Figma 의 큰 visual 영역, MDX 의 *title 만* 핵심)
- 좌측 DX circular area (multi-image + center text + decor — Figma 의 main
rhetorical anchor 단 compact zone 에서 표현 불가). title 의 gradient 로 의도 보존
- 한자/장식/배경 텍스처 — Figma source evidence 보존, 미 promote
ADAPTED — geometry only :
- Figma 1682×540 px 3-card canvas → zone-compatible 3-col grid
- 카드 내부 absolute 좌표 → flex column (bullet 개수 가변 대응)
- Figma 40px title / 37px body → token-fixed (sub-title / body)
ADAPTED :
- Figma 90/65/40px → token-fixed (zone-title 13 / sub-title 12 / caption / body 11)
- 1280×426 absolute positioning + zoom → Phase Z 3-column grid
- 3 perspective cards (header + body) → flex column
assets_dir : `assets/<template_id>` (run-relative). render() 호출 시 주입.
─────────────────────────────────────────────────────────────────────────────
min_height_px derivation :
3 col × (header 30 + body 3-bullets × 25) + title 30 + padding 30 = ~195
+ safety buffer (longer label / 5+ bullets) 125 = **320**. F12/F11/F18 class.
3 col × (header 50 + body 3-bullets × 26) + decor 60 + title 30 + padding 20
= ~238 + buffer 82 = **320**. F12/F11/F18 class.
─────────────────────────────────────────────────────────────────────────────
slots :
- title : section.title
- perspective_1/2/3_label : 3 column headers
- perspective_1/2/3_body : list[{text, indent}] : 3 column bullets
- perspective_1/2/3_label : 3 card headers
- perspective_1/2/3_body : list[{text, indent}] : 3 card bullets
─────────────────────────────────────────────────────────────────────────────
4-class failure taxonomy :
- class 1 readiness : builder reuse `quadrant_flat_slots` + `quadrant_item`.
contract + partial + smoke fixture.
- class 2 content-fit : header label 짧게 (single line). body 3-5 bullets per
column (zone fit). 6+ bullets 시 watch.
#}
<style>
@@ -58,7 +58,13 @@ slots :
gap: 6px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
/* Axis : zone aspect-ratio 에 따라 내부 layout 자동 회전 (사용자 lock 2026-05-15).
horizontal-2 zone (wide ~ 4.27 aspect) → 3 cards horizontal.
vertical-2 zone (~1 aspect) → 3 cards vertical. container query trigger. */
container-type: size;
container-name: f20b-root;
}
/* 중목차 (zone title) — Figma title gradient 보존, 사용자 지시로 미변경 */
.f20b__title {
font-size: var(--font-zone-title);
font-weight: 700;
@@ -70,84 +76,223 @@ slots :
filter: drop-shadow(0 0 3px rgba(50,44,30,0.4));
}
/* 3-column grid — Figma cards-3-header pattern */
.f20b__cols {
/* 3-card grid — Figma 우측 3 카드 영역 1:1.
::before 로 figma body_bg.png (hazy sky/cloud) + middle_overlay.png
(peach gradient) 두 layer 를 카드 영역 외곽에 깔아 figma 원본 분위기 재현.
zone title 은 이 영역 밖이라 영향 없음 (사용자 룰: 중목차 디자인 미변경). */
.f20b__cards {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
grid-template-rows: auto;
gap: 14px;
flex: 1 1 auto;
min-height: 0;
}
.f20b__col {
display: flex; flex-direction: column;
border: 2px solid #296B55; /* PROMOTED — green family from Figma */
border-radius: 8px;
padding: 10px 8px 12px;
border-radius: 12px;
overflow: hidden;
background: #fff;
min-height: 0;
}
/* zone aspect < 1.5 → 3 cards vertical stack. auto rows + align-content: start
= 콘텐츠 height 만큼, 위쪽 정렬 (좌/우 zone visual balance). */
@container f20b-root (aspect-ratio < 1.5) {
.f20b__cards {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
grid-auto-flow: row;
align-content: start;
}
}
.f20b__cards::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--frame-middle-overlay), var(--frame-body-bg);
background-size: 100% 100%, 100% 100%;
background-repeat: no-repeat;
background-position: center, center;
opacity: 0.9;
z-index: 0;
pointer-events: none;
}
/* header bar (top of each card, dark green per Figma) */
/* 개별 카드 — figma card_frame.png 가 외곽 stroke 담당.
z-index 1 = ::before (body_bg + middle_overlay) 위에 카드가 떠 있게.
Axis B fit (2026-05-14) — container-type: size 로 카드 body 안의 line-height
/ padding / gap 이 카드 height + 콘텐츠 line 수에 *수학적 비례* (cqh 단위).
zone 작아지면 자동 축소, 커지면 자동 확대. */
.f20b__card {
position: relative;
z-index: 1;
display: flex; flex-direction: column;
min-height: 0;
padding: 0 0 10px 0;
container-type: size;
}
/* card_frame.png — 외곽 line + 내부 light fill (figma 1:1) */
.f20b__card::before {
content: "";
position: absolute;
inset: 0 0 12px 0;
background-image: var(--card-frame-bg);
background-size: 100% 100%;
background-repeat: no-repeat;
z-index: 0;
pointer-events: none;
}
/* card_bottom_decor.png — 하단 장식. Axis B = 30% → 20% 로 body 공간 확보. */
.f20b__card::after {
content: "";
position: absolute;
left: 0; right: 0;
bottom: 0;
height: 22%;
background-image: var(--card-decor-bg);
background-size: 100% 100%;
background-repeat: no-repeat;
z-index: 1;
pointer-events: none;
}
/* card header — figma card_header_bg.png 배경 + white 카드 title. Axis B tight. */
.f20b__header {
background: linear-gradient(180deg, #296B55 0%, #123328 100%); /* PROMOTED — Figma green theme */
color: #fff;
position: relative;
z-index: 2;
margin: 4px 8px 0 8px;
padding: 3px 6px;
min-height: 28px;
display: flex; align-items: center; justify-content: center;
background-image: var(--card-header-bg);
background-size: 100% 100%;
background-repeat: no-repeat;
flex-shrink: 0;
}
.f20b__header-text {
font-weight: 700;
font-size: var(--font-sub-title);
line-height: 1.15;
padding: 6px 10px;
text-align: center;
flex-shrink: 0;
color: #ffffff;
/* figma 카드 title — white fill + 컬러 stroke + dark glow, paint-order stroke fill */
-webkit-text-stroke: 1px var(--card-title-stroke);
text-shadow: 0 0 3px #322c1e;
paint-order: stroke fill;
letter-spacing: -0.04em;
}
/* body — bullet list (CSS check marker, F14 pattern) */
/* card 1 / 3 = dark green stroke, card 2 = orange stroke (figma 1:1) */
.f20b__card--1 { --card-title-stroke: #1d4d3e; }
.f20b__card--2 { --card-title-stroke: #cc5200; }
.f20b__card--3 { --card-title-stroke: #1d4d3e; }
/* body — bullet list with bullet_arrow.png (figma asset).
figma 원본 본문 weight=500 (Medium) 인데 zone 크기에서는 header (700+stroke+glow)
와 시각적 차이가 작아 본문도 강조처럼 보임. font-size 룰 지켜야 하므로
weight 400 + 부드러운 color (#333) 로 명도 차이 확보. header = 무게+stroke,
body = 가볍고 어두운 검정. */
.f20b__body {
position: relative;
z-index: 2;
flex: 1 1 auto;
overflow: hidden;
padding: 6px 10px 8px;
color: #1a1a1a;
/* Axis B 비례 fit — padding/gap 도 카드 height 비례 (cqh).
카드 작을수록 자동 tight, 클수록 자동 여유. min/max clamp 로 극단 방지. */
padding: clamp(2px, 2cqh, 8px) clamp(6px, 3cqw, 12px) clamp(1px, 1cqh, 4px);
color: #333;
min-height: 0;
display: flex; flex-direction: column;
gap: 3px;
gap: clamp(0px, 0.6cqh, 4px);
}
.f20b__body .text-line {
color: inherit;
font-size: var(--font-body);
line-height: var(--lh-body);
/* line-height 핵심 산식 (사용자 lock 2026-05-14) :
카드 body 의 가용 height (~70cqh, header 28cqh / decor 22cqh 제외) 를
line count (--max-body-lines, jinja 에서 카드별 max bullet 수 주입) 로 나눠
per-line height. clamp 로 minimum (글자 겹침 방지) + maximum (너무 spaced
방지). font-size 미변경 (사용자 룰). */
line-height: clamp(
1.05em,
calc(70cqh / var(--max-body-lines, 5) - 0px),
1.5em
);
font-weight: 400;
letter-spacing: -0.02em;
position: relative;
padding-left: 14px;
min-height: 0;
}
.f20b__body .text-line--bullet::before {
content: "\2713"; /* ✓ check mark — green theme */
content: "";
position: absolute;
left: 0; top: 0;
color: #296B55; /* PROMOTED — green family */
font-weight: 700;
left: 0; top: 0.3em;
width: 10px; height: 9px;
background-image: var(--bullet-arrow);
background-size: contain;
background-repeat: no-repeat;
background-position: left center;
}
/* indent level (Phase Z mapper 가 indent 1+ 의 텍스트 line 을 시각적 sub-bullet
으로 표시할 때 사용). Axis B fit — padding-left 32 → 20 으로 zone 가용 폭 확보. */
.f20b__body .text-line--indent-1 { padding-left: 20px; color: #555; }
.f20b__body .text-line--indent-1::before { left: 8px; opacity: 0.65; }
</style>
<div class="f20b" data-frame-id="1171281198" data-template-id="dx_sw_necessity_three_perspectives">
{# assets_dir = 'assets/<template_id>' (run-relative). pipeline render() 가 주입.
url() 안의 single-quote 는 jinja autoescape (style= attribute) 가 &#39; 로 바꿔
CSS parse 가 실패. quote 빼고 (asset path 에 space/특수문자 없음) + `| safe`. #}
{% set _assets = assets_dir | default('', true) %}
{% set _frame_bg = "url(" ~ _assets ~ "/card_frame.png)" if _assets else 'none' %}
{% set _header_bg = "url(" ~ _assets ~ "/card_header_bg.png)" if _assets else 'none' %}
{% set _decor_bg = "url(" ~ _assets ~ "/card_bottom_decor.png)" if _assets else 'none' %}
{% set _bullet_bg = "url(" ~ _assets ~ "/bullet_arrow.png)" if _assets else 'none' %}
{% set _body_bg = "url(" ~ _assets ~ "/body_bg.png)" if _assets else 'none' %}
{% set _middle_ov = "url(" ~ _assets ~ "/middle_overlay.png)" if _assets else 'none' %}
<div class="f20b"
data-frame-id="1171281198"
data-template-id="dx_sw_necessity_three_perspectives"
style="--card-frame-bg: {{ _frame_bg | safe }};
--card-header-bg: {{ _header_bg | safe }};
--card-decor-bg: {{ _decor_bg | safe }};
--bullet-arrow: {{ _bullet_bg | safe }};
--frame-body-bg: {{ _body_bg | safe }};
--frame-middle-overlay: {{ _middle_ov | safe }};">
<div class="f20b__title">{{ slot_payload.title }}</div>
<div class="f20b__cols">
{# 3 columns — quadrant_flat_slots produces perspective_N_label / perspective_N_body for N=1..3 #}
<div class="f20b__col">
<div class="f20b__header">{{ slot_payload.perspective_1_label | safe }}</div>
{# Axis B 산식 — 카드별 bullet line 수 = body lines. card --max-body-lines 로
line-height clamp 계산에 사용. perspective_N_body 가 list 또는 None. #}
{% set p1_lines = (slot_payload.perspective_1_body | length) if slot_payload.perspective_1_body else 1 %}
{% set p2_lines = (slot_payload.perspective_2_body | length) if slot_payload.perspective_2_body else 1 %}
{% set p3_lines = (slot_payload.perspective_3_body | length) if slot_payload.perspective_3_body else 1 %}
<div class="f20b__cards">
{# 3 cards — quadrant_flat_slots produces perspective_N_label / perspective_N_body for N=1..3.
각 카드 inline --max-body-lines : 카드의 실제 line 수 → line-height 비례 산식. #}
<div class="f20b__card f20b__card--1" style="--max-body-lines: {{ p1_lines }};">
<div class="f20b__header">
<span class="f20b__header-text">{{ slot_payload.perspective_1_label | safe }}</span>
</div>
<div class="f20b__body">
{% if slot_payload.perspective_1_body %}
{% for line in slot_payload.perspective_1_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
</div>
<div class="f20b__col">
<div class="f20b__header">{{ slot_payload.perspective_2_label | safe }}</div>
<div class="f20b__card f20b__card--2" style="--max-body-lines: {{ p2_lines }};">
<div class="f20b__header">
<span class="f20b__header-text">{{ slot_payload.perspective_2_label | safe }}</span>
</div>
<div class="f20b__body">
{% if slot_payload.perspective_2_body %}
{% for line in slot_payload.perspective_2_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
</div>
<div class="f20b__col">
<div class="f20b__header">{{ slot_payload.perspective_3_label | safe }}</div>
<div class="f20b__card f20b__card--3" style="--max-body-lines: {{ p3_lines }};">
<div class="f20b__header">
<span class="f20b__header-text">{{ slot_payload.perspective_3_label | safe }}</span>
</div>
<div class="f20b__body">
{% if slot_payload.perspective_3_body %}
{% for line in slot_payload.perspective_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
@@ -40,6 +40,9 @@ slots: title (omitted), banner_left, banner_right,
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
overflow: hidden;
/* zone aspect 기반 내부 회전 (사용자 lock 2026-05-15) */
container-type: size;
container-name: f29b-root;
}
.f29b__title {
font-size: var(--font-zone-title); /* 13px — frame 13 의 f13b__title 과 동일 위계 */
@@ -119,37 +122,46 @@ slots: title (omitted), banner_left, banner_right,
linear-gradient(to bottom, rgba(41,107,85,0.3) 0%, rgba(255,255,255,0.3) 100%) 100% 0% / 50% 100% no-repeat;
}
/* 6 cell grid: 3 rows (section 1/2/3), 2 cols (process/product). 각 row 의 height = max(좌, 우) */
/* 6 cell grid: 3 rows (section 1/2/3), 2 cols (process/product).
Axis B (zone-fit, 2026-05-14) — minmax(0,1fr) 실험 결과 균등 분할로 *역효과*
(짧은 row 가 공간 낭비, 긴 row 가 더 짤림). grid-auto-rows: auto + grid
container overflow: hidden + padding/gap tight. font-size 미변경. */
.f29b__grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: auto;
gap: 6px 10px;
padding: 4px 8px;
gap: 4px 8px;
padding: 3px 6px;
height: 100%;
align-content: start;
min-height: 0;
overflow: hidden;
}
/* 회전 비활성 — 사용자 lock 2026-05-15 : 03-2 는 기존 2 col layout 이 더 자연스러움.
(회전은 03-1 의 3 pillars / 3 cards 만 의도). */
.f29b__cell {
overflow: hidden;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* row 2/3 (3 번째 cell 이후) 위에 dashed divider — 좌우 같은 Y */
/* row 2/3 (3 번째 cell 이후) 위에 dashed divider — 좌우 같은 Y. fit 정비로 tight. */
.f29b__cell:nth-child(n+3) {
border-top: 1px dashed rgba(0,0,0,0.15);
padding-top: 6px;
margin-top: 4px;
padding-top: 3px;
margin-top: 2px;
}
/* section-title — 부소목차 (사용자 5-layer 직설 2026-05-07): Analogue 기반... 같은 깊이 0 굵은 bullet */
.f29b__section-title {
font-size: var(--font-sub-title); /* 12px — 부소목차 위계 */
font-weight: 900;
line-height: 1.3;
margin-bottom: 5px;
line-height: 1.25;
margin-bottom: 3px;
padding-left: 6px;
border-left: 3px solid currentColor;
flex-shrink: 0;
}
.f29b__cell--left .f29b__section-title { color: #5C3714; } /* PROMOTED line 125 */
.f29b__cell--right .f29b__section-title { color: #084C56; } /* PROMOTED line 126 */
@@ -157,9 +169,28 @@ slots: title (omitted), banner_left, banner_right,
font-size: var(--font-caption); /* 10px */
margin-left: 3px;
}
/* f29b 의 본문 = .text-line 공통 contract 사용. bullet color override. */
/* f29b 의 본문 = .text-line 공통 contract 사용. bullet color override + Axis B
local line-height (token --lh-body 보다 살짝 tight). font-size 미변경 (사용자 룰).
공통 여백 token 은 안 건드리고 frame 내부 local override 만. */
.f29b__cell .text-line {
line-height: 1.3;
margin-top: 0;
margin-bottom: 0;
}
.f29b__cell .text-line + .text-line { margin-top: 1px; }
.f29b__cell .text-line--bullet { color: #000; font-weight: 700; }
/* popup link — 각 column 끝, 파란색 글씨 (사용자 lock 2026-05-14). */
.f29b__popup-link {
margin-top: 2px;
font-size: var(--font-caption);
font-weight: 600;
color: #1a73e8;
cursor: pointer;
text-decoration: underline;
}
.f29b__popup-link::before { content: "▶ "; font-size: 0.85em; }
/* AS-IS/TO-BE 좌우 + arrow (PROMOTED 구조) */
.f29b__transform {
display: grid;
@@ -215,80 +246,71 @@ slots: title (omitted), banner_left, banner_right,
<div class="f29b__grid">
{# ───── Row 1: section 1 ───── #}
{% set s1 = slot_payload.process.sections[0] %}
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
{% if s1.title %}
<div class="f29b__section-title">{{ s1.title | safe }}</div>
{% if s1.transforms %}
<div class="transform-block">
<div class="transform-block__header">
<span class="transform-block__label--from">AS-IS</span>
<span></span>
<span class="transform-block__label--to">TO-BE</span>
</div>
<div class="transform-rows">
{% for t in s1.transforms %}
<div class="transform-row">
<div class="transform-row__from">{{ t.from | safe }}</div>
<div class="transform-row__arrow">➜</div>
<div class="transform-row__to">{{ t.to | safe }}</div>
{# 2026-05-14 — dynamic cardinality loop. row 0..N-1, N = max(process.sections, product.sections).
원본 콘텐츠 N cards 가 어떤 값이든 (3 / 4 / 5 ...) 그대로 표시. 부족한 쪽은 빈 cell.
row 0 의 process column 만 AS-IS/TO-BE transform block 지원 (legacy 03 호환). #}
{% set proc_len = slot_payload.process.sections | length %}
{% set prod_len = slot_payload.product.sections | length %}
{% set row_count = [proc_len, prod_len] | max %}
{% for r in range(row_count) %}
{% set s = slot_payload.process.sections[r] if r < proc_len else none %}
{% set p = slot_payload.product.sections[r] if r < prod_len else none %}
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
{% if s and s.title %}
<div class="f29b__section-title">{{ s.title | safe }}</div>
{% if s.transforms %}
<div class="transform-block">
<div class="transform-block__header">
<span class="transform-block__label--from">AS-IS</span>
<span></span>
<span class="transform-block__label--to">TO-BE</span>
</div>
<div class="transform-rows">
{% for t in s.transforms %}
<div class="transform-row">
<div class="transform-row__from">{{ t.from | safe }}</div>
<div class="transform-row__arrow">➜</div>
<div class="transform-row__to">{{ t.to | safe }}</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% elif s.text_lines %}
{% for line in s.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
{% endif %}
</div>
{% elif s1.text_lines %}
{% for line in s1.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
{% endif %}
</div>
{% set p1 = slot_payload.product.sections[0] %}
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
{% if p1.title %}
<div class="f29b__section-title">{{ p1.title | safe }}</div>
{% for line in p1.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
{# ───── Row 2: section 2 ───── #}
{% set s2 = slot_payload.process.sections[1] %}
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
{% if s2.title %}
<div class="f29b__section-title">{{ s2.title | safe }}</div>
{% for line in s2.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
{% set p2 = slot_payload.product.sections[1] %}
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
{% if p2.title %}
<div class="f29b__section-title">{{ p2.title | safe }}</div>
{% for line in p2.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% if p2.footnote %}
<div class="f29b__footnote"><span class="f29b__aster-small">*</span> {{ p2.footnote | safe }}</div>
{% endif %}
{% endif %}
</div>
{# ───── Row 3: section 3 ───── #}
{% set s3 = slot_payload.process.sections[2] %}
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
{% if s3.title %}
<div class="f29b__section-title">{{ s3.title | safe }}</div>
{% for line in s3.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
{% set p3 = slot_payload.product.sections[2] %}
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
{% if p3.title %}
<div class="f29b__section-title">{{ p3.title | safe }}{% if p3.sub_title %}<span class="f29b__section-title__sub">{{ p3.sub_title | safe }}</span>{% endif %}</div>
{% for line in p3.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
{% if p and p.title %}
<div class="f29b__section-title">{{ p.title | safe }}{% if p.sub_title %}<span class="f29b__section-title__sub">{{ p.sub_title | safe }}</span>{% endif %}</div>
{% for line in p.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% if p.footnote %}
<div class="f29b__footnote"><span class="f29b__aster-small">*</span> {{ p.footnote | safe }}</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{# 2026-05-14 — popup links : 좌/우 column 끝에 파란색 글씨 (사용자 lock).
slot_payload.popup_left_summary / popup_right_summary 가 있으면 표시. #}
{% if slot_payload.popup_left_summary or slot_payload.popup_right_summary %}
<div class="f29b__popups" style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; padding: 0 8px;">
<div>
{% if slot_payload.popup_left_summary %}
<div class="f29b__popup-link">{{ slot_payload.popup_left_summary | safe }}</div>
{% endif %}
</div>
<div>
{% if slot_payload.popup_right_summary %}
<div class="f29b__popup-link">{{ slot_payload.popup_right_summary | safe }}</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
@@ -4,34 +4,38 @@
─────────────────────────────────────────────────────────────────────────────
Visual Provenance — figma_to_html_agent/blocks/1171281209/ (frame 28)
─────────────────────────────────────────────────────────────────────────────
Frame 28 = "현존 상용 S/W의 현실" (cards/title-plus-3-emphasis). 3 emphasis
cards parallel — 3 problem/약점/한계 진단 (토목 전문성 부족 / 비효율성 /
실무 적용 불가).
Frame 28 = "현존 상용 S/W의 현실".
사용자 lock (2026-05-14) : figma 의 1 frame visual (외곽 빨간 border + pill +
본문) 을 *3 개 두루마리로 복제*, horizontal 3-column.
- 각 두루마리 box : 외곽 빨간 border (#9C0E0E) + radius
- 상단 pill (title_pill.png) : emphasis_N_label (예 : "토목 전문성 부족")
- 본문 paragraph : emphasis_N_body 의 text-line 들 (▶ bullet + .accent)
본 partial = Track A frame 7 (V4-zero catalog-completeness activation per
Codex round 49 guardrail). V4 = matching authority 보존. Phase Z 는 runtime
availability 만 추가.
slots :
- title : zone 중목차 (별도 위 영역)
- emphasis_1/2/3_label : 각 두루마리 pill 안
- emphasis_1/2/3_body : list[{text}] : 각 두루마리 본문
PROMOTED CSS :
- per-emphasis 경고 색 (red/orange/amber — problem theme)
- title gradient (zone-title family)
- bullet markers per-emphasis color
NOT PROMOTED : Figma source decoration / banner / texture — 보존 (figma_to_html).
ADAPTED : Figma absolute → flex 3-column grid + token-fixed typography.
slots : title, emphasis_1/2/3_label, emphasis_1/2/3_body.
Axis B 수학 fit (cqh + jinja line count) :
- border / pill / padding / bullet size = clamp(min, cqh/cqw 비례, max)
- line-height = clamp(1.1em, calc(가용 cqh / N), 1.5em),
N = jinja 가 emphasis_N_body | length 로 계산해 inline --max-body-lines 주입
- font-size 미변경 (사용자 룰)
─────────────────────────────────────────────────────────────────────────────
#}
<style>
.f28b {
width: 100%; height: 100%;
display: flex; flex-direction: column;
gap: 6px;
gap: 4px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
/* zone aspect 기반 내부 회전 (사용자 lock) */
container-type: size;
container-name: f28b-root;
}
/* 중목차 (zone title) — gradient 보존, 사용자 룰 (안 건드림). */
.f28b__title {
font-size: var(--font-zone-title);
font-weight: 700;
@@ -43,91 +47,195 @@ slots : title, emphasis_1/2/3_label, emphasis_1/2/3_body.
filter: drop-shadow(0 0 3px rgba(50,44,30,0.4));
}
.f28b__cols {
/* ── 3 두루마리 grid — zone aspect 에 따라 horizontal/vertical 회전 ── */
.f28b__cards {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
grid-template-rows: auto;
gap: clamp(6px, 1.5cqw, 14px);
flex: 1 1 auto;
min-height: 0;
padding-top: clamp(6px, 3cqh, 12px);
}
.f28b__col {
display: flex; flex-direction: column;
border: 2px solid;
border-radius: 8px;
overflow: hidden;
background: #fff;
min-height: 0;
/* zone aspect < 1.5 → 3 두루마리 vertical stack. auto rows + align-content: start
= 콘텐츠 height 만큼, 위쪽 정렬 (좌/우 zone visual balance). */
@container f28b-root (aspect-ratio < 1.5) {
.f28b__cards {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
grid-auto-flow: row;
align-content: start;
}
}
/* per-emphasis problem theme — orange/red family for 약점/한계 진단 */
.f28b__col:nth-child(1) { border-color: #dc2626; } /* red */
.f28b__col:nth-child(2) { border-color: #ea580c; } /* orange */
.f28b__col:nth-child(3) { border-color: #d97706; } /* amber */
.f28b__header {
color: #fff;
font-weight: 700;
/* 두루마리 박스 — figma 1 frame 의 visual 정체성 1:1. 사용자 lock 2026-05-14
"두루마리 좀 더 크게" → padding-top 줄여 body 영역 확장. */
.f28b__card {
position: relative;
display: flex; flex-direction: column;
min-height: 0;
border: clamp(3px, 1.2cqh, 6px) solid #9C0E0E;
border-radius: clamp(6px, 1.5cqh, 12px);
background: linear-gradient(to bottom right, #fff 0%, #FBEDE8 100%);
container-type: size;
overflow: visible;
padding-top: clamp(10px, 5cqh, 16px);
}
/* 상단 pill — emphasis label 텍스트가 figma 의 red→darkred gradient + glow.
title_pill.png asset 이 있으면 배경, 없으면 빨간 fallback. */
.f28b__pill {
position: absolute;
top: clamp(-12px, -3cqh, -4px);
left: 50%;
transform: translateX(-50%);
min-width: 60%;
max-width: 90%;
height: clamp(14px, 11cqh, 26px);
background-image: var(--pill-bg);
background-size: 100% 100%;
background-repeat: no-repeat;
background-color: #9C0E0E;
border-radius: 9999px;
z-index: 3;
display: flex; align-items: center; justify-content: center;
padding: 0 clamp(6px, 2cqw, 12px);
box-shadow: 0 2px 4px rgba(0,0,0,0.18);
}
.f28b__pill-text {
font-weight: 900;
font-size: var(--font-sub-title);
line-height: 1.15;
padding: 6px 10px;
text-align: center;
flex-shrink: 0;
letter-spacing: -0.02em;
}
.f28b__col:nth-child(1) .f28b__header { background: linear-gradient(180deg, #dc2626 0%, #991b1b 100%); }
.f28b__col:nth-child(2) .f28b__header { background: linear-gradient(180deg, #ea580c 0%, #9a3412 100%); }
.f28b__col:nth-child(3) .f28b__header { background: linear-gradient(180deg, #d97706 0%, #92400e 100%); }
.f28b__body {
flex: 1 1 auto;
line-height: 1;
color: #fff;
letter-spacing: -0.03em;
white-space: nowrap;
overflow: hidden;
padding: 6px 10px 8px;
color: #1a1a1a;
text-overflow: ellipsis;
max-width: 100%;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
/* 두루마리 본문 — line 별 indent 구조 (indent 0 = 소소목차 ✓ / indent 1 = 서브 ▶) */
.f28b__body {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex; flex-direction: column;
gap: 3px;
overflow: hidden;
display: flex;
flex-direction: column;
gap: clamp(2px, 1cqh, 6px);
padding: clamp(4px, 2cqh, 10px) clamp(6px, 2cqw, 14px) clamp(4px, 2cqh, 10px);
/* line-height 산식 — body 가용 cqh ≈ 75. N = jinja 가 line 수 주입. */
line-height: clamp(
1.2em,
calc(75cqh / var(--max-body-lines, 4)),
1.7em
);
font-size: var(--font-body);
color: #1a1a1a;
}
.f28b__body .text-line {
color: inherit;
font-size: var(--font-body);
line-height: var(--lh-body);
position: relative;
padding-left: 14px;
padding-left: clamp(14px, 4cqw, 22px);
line-height: inherit;
}
.f28b__body .text-line--bullet::before {
content: "!";
position: absolute;
left: 4px; top: 0;
/* indent 0 — 소소목차 볼드체 (mdx 의 <strong>) 앞에 ✓ 빨간 마크 (사용자 lock) */
.f28b__body .text-line--heading {
font-weight: 700;
color: #1a1a1a;
margin-top: clamp(1px, 0.5cqh, 4px);
}
.f28b__col:nth-child(1) .f28b__body .text-line--bullet::before { color: #dc2626; }
.f28b__col:nth-child(2) .f28b__body .text-line--bullet::before { color: #ea580c; }
.f28b__col:nth-child(3) .f28b__body .text-line--bullet::before { color: #d97706; }
.f28b__body .text-line--heading:first-child { margin-top: 0; }
.f28b__body .text-line--heading::before {
content: "\2713"; /* ✓ */
position: absolute;
left: clamp(0px, 0.5cqw, 4px);
top: 0;
font-weight: 900;
color: #9C0E0E;
line-height: inherit;
}
/* indent 1+ — 서브 detail 본문 앞에 ▶ bullet_arrow.png (figma asset). 들여쓰기. */
.f28b__body .text-line--sub {
padding-left: clamp(22px, 6cqw, 32px);
color: #444;
font-weight: 400;
}
.f28b__body .text-line--sub::before {
content: "";
position: absolute;
left: clamp(10px, 3cqw, 16px);
top: 0.45em;
width: clamp(7px, 2cqw, 11px);
height: clamp(6px, 1.8cqw, 10px);
background-image: var(--bullet-arrow);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
/* asset 없을 때 fallback — 작은 ▶ 글자 */
.f28b--no-assets .f28b__body .text-line--sub::before {
content: "\25B8"; /* ▸ */
background: none;
width: auto;
height: auto;
top: 0;
color: #9C0E0E;
font-weight: 700;
line-height: inherit;
}
/* mdx text 안 <strong> 보존 — accent 강조는 .accent 클래스도 인식 */
.f28b__body .text-line strong { color: #1a1a1a; }
.f28b__body .text-line--heading strong { color: inherit; }
.f28b__body .text-line .accent { color: #CC5200; font-weight: 700; }
</style>
<div class="f28b" data-frame-id="1171281209" data-template-id="sw_reality_three_emphasis">
{% set _assets = assets_dir | default('', true) %}
{% set _bullet_bg = "url(" ~ _assets ~ "/bullet_arrow.png)" if _assets else 'none' %}
{% set _pill_bg = "url(" ~ _assets ~ "/title_pill.png)" if _assets else 'none' %}
{% set e1_lines = (slot_payload.emphasis_1_body | length) if slot_payload.emphasis_1_body else 1 %}
{% set e2_lines = (slot_payload.emphasis_2_body | length) if slot_payload.emphasis_2_body else 1 %}
{% set e3_lines = (slot_payload.emphasis_3_body | length) if slot_payload.emphasis_3_body else 1 %}
<div class="f28b{% if not _assets %} f28b--no-assets{% endif %}"
data-frame-id="1171281209"
data-template-id="sw_reality_three_emphasis"
style="--bullet-arrow: {{ _bullet_bg | safe }};
--pill-bg: {{ _pill_bg | safe }};">
<div class="f28b__title">{{ slot_payload.title }}</div>
<div class="f28b__cols">
<div class="f28b__col">
<div class="f28b__header">{{ slot_payload.emphasis_1_label | safe }}</div>
<div class="f28b__cards">
<div class="f28b__card" style="--max-body-lines: {{ e1_lines }};">
<div class="f28b__pill">
<span class="f28b__pill-text">{{ slot_payload.emphasis_1_label | safe }}</span>
</div>
<div class="f28b__body">
{% if slot_payload.emphasis_1_body %}
{% for line in slot_payload.emphasis_1_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% for line in slot_payload.emphasis_1_body %}<div class="text-line {% if line.indent and line.indent > 0 %}text-line--sub{% else %}text-line--heading{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
</div>
<div class="f28b__col">
<div class="f28b__header">{{ slot_payload.emphasis_2_label | safe }}</div>
<div class="f28b__card" style="--max-body-lines: {{ e2_lines }};">
<div class="f28b__pill">
<span class="f28b__pill-text">{{ slot_payload.emphasis_2_label | safe }}</span>
</div>
<div class="f28b__body">
{% if slot_payload.emphasis_2_body %}
{% for line in slot_payload.emphasis_2_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% for line in slot_payload.emphasis_2_body %}<div class="text-line {% if line.indent and line.indent > 0 %}text-line--sub{% else %}text-line--heading{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
</div>
<div class="f28b__col">
<div class="f28b__header">{{ slot_payload.emphasis_3_label | safe }}</div>
<div class="f28b__card" style="--max-body-lines: {{ e3_lines }};">
<div class="f28b__pill">
<span class="f28b__pill-text">{{ slot_payload.emphasis_3_label | safe }}</span>
</div>
<div class="f28b__body">
{% if slot_payload.emphasis_3_body %}
{% for line in slot_payload.emphasis_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% for line in slot_payload.emphasis_3_body %}<div class="text-line {% if line.indent and line.indent > 0 %}text-line--sub{% else %}text-line--heading{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
</div>
</div>
@@ -34,6 +34,9 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
gap: 4px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
/* zone aspect 기반 내부 회전 (사용자 lock 2026-05-15) */
container-type: size;
container-name: f13b-root;
}
.f13b__title {
font-size: var(--font-zone-title);
@@ -47,10 +50,22 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
.f13b__cols {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: auto;
gap: 8px;
flex: 1;
min-height: 0;
}
/* zone aspect < 1.5 → 3 pillars vertical stack.
auto rows + align-content: start = 콘텐츠 height 만큼, 위쪽 정렬 (사용자 lock
2026-05-15 : 03-2 와 좌/우 visual balance 위해 콘텐츠 위로 올림). */
@container f13b-root (aspect-ratio < 1.5) {
.f13b__cols {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
grid-auto-flow: row;
align-content: start;
}
}
.f13b__col {
display: flex;
border-top: 2px solid #000; /* PROMOTED line 57 */
+1 -26
View File
@@ -1,23 +1,11 @@
<!-- 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"{% if embedded_mode == "embedded" %} class="embedded"{% endif %}>
<html lang="ko">
<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 }}
@@ -32,19 +20,6 @@
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;
File diff suppressed because it is too large Load Diff
@@ -1,43 +0,0 @@
input:
layout_preset: grid-2x2
zones_data:
- position: top-left
template_id: MOCK_top-left
content_weight:
score: 0.25
min_height_px: 200
- position: top-right
template_id: MOCK_top-right
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-left
template_id: MOCK_bottom-left
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-right
template_id: MOCK_bottom-right
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"top-left top-right" "bottom-left bottom-right"'
cols: 583px 583px
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 583
- 583
ratios:
- 0.489
- 0.487
width_ratios:
- 0.494
- 0.494
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -1,63 +0,0 @@
input:
layout_preset: grid-2x2
zones_data:
- position: top-left
template_id: MOCK_top-left
content_weight:
score: 0.25
min_height_px: 200
- position: top-right
template_id: MOCK_top-right
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-left
template_id: MOCK_bottom-left
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-right
template_id: MOCK_bottom-right
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries:
top-left:
x: 0
y: 0
w: 0.55
h: 0.4
top-right:
x: 0.55
y: 0
w: 0.45
h: 0.4
bottom-left:
x: 0
y: 0.4
w: 0.55
h: 0.6
bottom-right:
x: 0.55
y: 0.4
w: 0.45
h: 0.6
expected_layout_css:
areas: '"top-left top-right" "bottom-left bottom-right"'
cols: 641px 525px
rows: 228px 343px
heights_px:
- 228
- 343
widths_px:
- 641
- 525
ratios:
- 0.4
- 0.6
width_ratios:
- 0.55
- 0.45
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -1,38 +0,0 @@
input:
layout_preset: left-1-right-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.5
min_height_px: 200
- position: right-top
template_id: MOCK_right-top
content_weight:
score: 0.25
min_height_px: 200
- position: right-bottom
template_id: MOCK_right-bottom
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"left right-top" "left right-bottom"'
cols: 777px 389px
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 777
- 389
ratios:
- 0.489
- 0.487
width_ratios:
- 0.658
- 0.33
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -1,53 +0,0 @@
input:
layout_preset: left-1-right-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.5
min_height_px: 200
- position: right-top
template_id: MOCK_right-top
content_weight:
score: 0.25
min_height_px: 200
- position: right-bottom
template_id: MOCK_right-bottom
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries:
left:
x: 0
y: 0
w: 0.4
h: 1.0
right-top:
x: 0.4
y: 0
w: 0.6
h: 0.5
right-bottom:
x: 0.4
y: 0.5
w: 0.6
h: 0.5
expected_layout_css:
areas: '"left right-top" "left right-bottom"'
cols: 466px 700px
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 466
- 700
ratios:
- 0.5
- 0.5
width_ratios:
- 0.4
- 0.6
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -1,38 +0,0 @@
input:
layout_preset: left-2-right-1
zones_data:
- position: left-top
template_id: MOCK_left-top
content_weight:
score: 0.25
min_height_px: 200
- position: left-bottom
template_id: MOCK_left-bottom
content_weight:
score: 0.25
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"left-top right" "left-bottom right"'
cols: 389px 777px
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 389
- 777
ratios:
- 0.489
- 0.487
width_ratios:
- 0.33
- 0.658
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -1,53 +0,0 @@
input:
layout_preset: left-2-right-1
zones_data:
- position: left-top
template_id: MOCK_left-top
content_weight:
score: 0.25
min_height_px: 200
- position: left-bottom
template_id: MOCK_left-bottom
content_weight:
score: 0.25
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries:
left-top:
x: 0
y: 0
w: 0.35
h: 0.6
left-bottom:
x: 0
y: 0.6
w: 0.35
h: 0.4
right:
x: 0.35
y: 0
w: 0.65
h: 1.0
expected_layout_css:
areas: '"left-top right" "left-bottom right"'
cols: 408px 758px
rows: 343px 228px
heights_px:
- 343
- 228
widths_px:
- 408
- 758
ratios:
- 0.6
- 0.4
width_ratios:
- 0.35
- 0.65
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -1,38 +0,0 @@
input:
layout_preset: top-1-bottom-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.5
min_height_px: 200
- position: bottom-left
template_id: MOCK_bottom-left
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-right
template_id: MOCK_bottom-right
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"top top" "bottom-left bottom-right"'
cols: 583px 583px
rows: 314px 257px
heights_px:
- 314
- 257
widths_px:
- 583
- 583
ratios:
- 0.537
- 0.439
width_ratios:
- 0.494
- 0.494
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -1,53 +0,0 @@
input:
layout_preset: top-1-bottom-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.5
min_height_px: 200
- position: bottom-left
template_id: MOCK_bottom-left
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-right
template_id: MOCK_bottom-right
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries:
top:
x: 0
y: 0
w: 1.0
h: 0.3
bottom-left:
x: 0
y: 0.3
w: 0.5
h: 0.7
bottom-right:
x: 0.5
y: 0.3
w: 0.5
h: 0.7
expected_layout_css:
areas: '"top top" "bottom-left bottom-right"'
cols: 583px 583px
rows: 171px 400px
heights_px:
- 171
- 400
widths_px:
- 583
- 583
ratios:
- 0.3
- 0.7
width_ratios:
- 0.5
- 0.5
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -1,38 +0,0 @@
input:
layout_preset: top-2-bottom-1
zones_data:
- position: top-left
template_id: MOCK_top-left
content_weight:
score: 0.25
min_height_px: 200
- position: top-right
template_id: MOCK_top-right
content_weight:
score: 0.25
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"top-left top-right" "bottom bottom"'
cols: 583px 583px
rows: 257px 314px
heights_px:
- 257
- 314
widths_px:
- 583
- 583
ratios:
- 0.439
- 0.537
width_ratios:
- 0.494
- 0.494
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -1,53 +0,0 @@
input:
layout_preset: top-2-bottom-1
zones_data:
- position: top-left
template_id: MOCK_top-left
content_weight:
score: 0.25
min_height_px: 200
- position: top-right
template_id: MOCK_top-right
content_weight:
score: 0.25
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries:
top-left:
x: 0
y: 0
w: 0.6
h: 0.4
top-right:
x: 0.6
y: 0
w: 0.4
h: 0.4
bottom:
x: 0
y: 0.4
w: 1.0
h: 0.6
expected_layout_css:
areas: '"top-left top-right" "bottom bottom"'
cols: 700px 466px
rows: 228px 343px
heights_px:
- 228
- 343
widths_px:
- 700
- 466
ratios:
- 0.4
- 0.6
width_ratios:
- 0.6
- 0.4
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -1,24 +0,0 @@
case_id: grid-2x2_dynamic_2d
description: |
grid-2x2 (2x2 topology) is promoted to 2-D dynamic in IMP-09 PR 2.
Row-axis retry MUST be skipped by the gate with the
"dynamic_cols (2-D topology)" reason.
input_layout_css:
areas: '"top-left top-right" "bottom-left bottom-right"'
cols: 583px 583px
rows: 286px 285px
heights_px: [286, 285]
widths_px: [583, 583]
ratios: [0.489, 0.487]
width_ratios: [0.494, 0.494]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
@@ -1,24 +0,0 @@
case_id: left-1-right-2_dynamic_2d
description: |
left-1-right-2 (side-T-left topology) is promoted to 2-D dynamic in
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
"dynamic_cols (2-D topology)" reason.
input_layout_css:
areas: '"left right-top" "left right-bottom"'
cols: 777px 389px
rows: 286px 285px
heights_px: [286, 285]
widths_px: [777, 389]
ratios: [0.489, 0.487]
width_ratios: [0.658, 0.33]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
@@ -1,24 +0,0 @@
case_id: left-2-right-1_dynamic_2d
description: |
left-2-right-1 (side-T-right topology) is promoted to 2-D dynamic in
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
"dynamic_cols (2-D topology)" reason.
input_layout_css:
areas: '"left-top right" "left-bottom right"'
cols: 389px 777px
rows: 286px 285px
heights_px: [286, 285]
widths_px: [389, 777]
ratios: [0.489, 0.487]
width_ratios: [0.33, 0.658]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
@@ -1,26 +0,0 @@
case_id: top-1-bottom-2_dynamic_2d
description: |
top-1-bottom-2 (T topology) is promoted to 2-D dynamic in IMP-09
PR 2 (dynamic_rows=True, dynamic_cols=True). Row-axis retry MUST be
skipped by the IMP-09 gate with the "dynamic_cols (2-D topology)"
skip reason, because row-only redistribution cannot reconcile both
axes simultaneously.
input_layout_css:
areas: '"top top" "bottom-left bottom-right"'
cols: 583px 583px
rows: 314px 257px
heights_px: [314, 257]
widths_px: [583, 583]
ratios: [0.537, 0.439]
width_ratios: [0.494, 0.494]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
@@ -1,24 +0,0 @@
case_id: top-2-bottom-1_dynamic_2d
description: |
top-2-bottom-1 (inverted-T topology) is promoted to 2-D dynamic in
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
"dynamic_cols (2-D topology)" reason.
input_layout_css:
areas: '"top-left top-right" "bottom bottom"'
cols: 583px 583px
rows: 257px 314px
heights_px: [257, 314]
widths_px: [583, 583]
ratios: [0.439, 0.537]
width_ratios: [0.494, 0.494]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
+8 -8
View File
@@ -138,21 +138,21 @@ def test_vertical_2_override_keeps_fr_cols_legacy():
assert result["width_ratios"] == [0.4, 0.6]
# ────────────────────── 2-D dynamic dispatch (PR 2) ──────────────────────
# ────────────────────── fr_default sink (PR 1) ──────────────────────
def test_top_1_bottom_2_dynamic_2d_populates_geometry():
"""T-shape (top-1-bottom-2) is dispatched through the 2-D dynamic
builder in PR 2: heights_px / widths_px length-locked to grid
R=2, C=2 with both dynamic flags True."""
def test_top_1_bottom_2_fr_default_populates_geometry():
"""T-shape (top-1-bottom-2) falls through to fr_default in PR 1
but heights_px / widths_px must be populated (length-locked to
grid R=2, C=2)."""
zones = [
_zone("top", 0.5),
_zone("bottom-left", 0.25),
_zone("bottom-right", 0.25),
]
result = build_layout_css("top-1-bottom-2", zones)
assert result["computation"] == "2d_dynamic_aggregated"
assert result["dynamic_rows"] is True
assert result["dynamic_cols"] is True
assert result["computation"] == "fr_default_from_preset"
assert result["dynamic_rows"] is False
assert result["dynamic_cols"] is False
assert len(result["heights_px"]) == 2 # R rows
assert len(result["widths_px"]) == 2 # C cols
@@ -1,89 +0,0 @@
"""IMP-12 u11 — plan_cross_zone_redistribute tests.
Stage 2 contract (unit u11):
- multi-role zone feasible (deficit role + surplus role in the same zone)
- single-role zone infeasible reason (no peer to donate surplus)
u4 wraps fit_verifier.redistribute() in the Step-17 plan signature; feasibility
depends on whether deficit roles can be covered by surplus roles within the
same container.zone group (see src/fit_verifier.py:496-590). The plan exposes
role_heights_before / role_heights_after and surfaces the
'can_redistribute=False — single-role zone(s)' substring when redistribution
is impossible. The apply helper must scope output to [data-role=...] only
(feedback_phase_z_spacing_direction — no :root / body / .slide / .zone).
"""
from __future__ import annotations
from src.fit_verifier import FitAnalysis, RoleFit
from src.phase_z2_retry import (
apply_cross_zone_redistribute_css,
plan_cross_zone_redistribute,
)
def _fit(roles: dict[str, tuple[float, float]]) -> FitAnalysis:
"""roles dict = {role: (allocated_px, shortfall_px)}.
Sign convention matches fit_verifier.redistribute: shortfall_px > 0 = deficit,
shortfall_px < 0 = surplus (usable = abs(shortfall) - min_margin_px).
"""
return FitAnalysis(
roles={
name: RoleFit(role=name, allocated_px=alloc, shortfall_px=short)
for name, (alloc, short) in roles.items()
}
)
def test_multi_role_zone_feasible():
"""Two roles in the same zone — deficit covered by surplus → feasible."""
fit = _fit({"top": (200.0, 30.0), "bottom_l": (300.0, -50.0)})
containers = {
"top": {"zone": "slide_body", "height_px": 200},
"bottom_l": {"zone": "slide_body", "height_px": 300},
}
plan = plan_cross_zone_redistribute(
fit_analysis=fit, containers=containers, min_margin_px=10.0,
)
assert plan["action"] == "cross_zone_redistribute"
assert plan["feasible"] is True
assert plan["can_redistribute"] is True
assert plan["role_heights_before"] == {"top": 200.0, "bottom_l": 300.0}
after = plan["role_heights_after"]
# deficit (30) shifts top up, surplus (50-margin=40) shifts bottom_l down by 30.
assert after["top"] > 200.0
assert after["bottom_l"] < 300.0
assert abs((after["top"] - 200.0) - (300.0 - after["bottom_l"])) < 1.0
css = apply_cross_zone_redistribute_css(plan)
assert '[data-role="top"]' in css
assert '[data-role="bottom_l"]' in css
# Scope lock — no global rules emitted.
for forbidden in (":root", "body", ".slide", ".zone"):
assert forbidden not in css
def test_single_role_zone_infeasible_reason():
"""Lone role in a zone has no peer to donate surplus → infeasible."""
fit = _fit({"top": (200.0, 30.0)})
containers = {"top": {"zone": "slide_body", "height_px": 200}}
plan = plan_cross_zone_redistribute(
fit_analysis=fit, containers=containers, min_margin_px=10.0,
)
assert plan["feasible"] is False
assert plan["can_redistribute"] is False
reason = plan["failure_reason"]
assert "single-role zone" in reason
assert "can_redistribute=False" in reason
# apply emits nothing when infeasible.
assert apply_cross_zone_redistribute_css(plan) == ""
def test_empty_fit_analysis_infeasible():
"""No roles at all → defensive infeasible (cannot redistribute nothing)."""
plan = plan_cross_zone_redistribute(
fit_analysis=FitAnalysis(roles={}), containers={}, min_margin_px=10.0,
)
assert plan["feasible"] is False
assert plan["role_heights_before"] == {}
assert "no roles" in plan["failure_reason"]
assert apply_cross_zone_redistribute_css(plan) == ""
@@ -1,119 +0,0 @@
"""IMP-12 u14 — failure_router cascade tests.
Stage 2 contract (unit u14):
- donor_slack_insufficient → cross_zone_redistribute (impl=IMPLEMENTED)
- 3 new failure types (cross_zone_redistribute_insufficient,
glue_absorption_insufficient, font_step_insufficient) all route to
expected next actions per the locked NEXT_ACTION_BY_FAILURE table
- rerender_still_fails preserved → frame_reselect
u2 (classifier) inspects retry_trace["salvage_steps"][-1] for the 3 new
salvage failure types via SALVAGE_FAILURE_TYPE_BY_ACTION; u3 wires those
failure types onto the deterministic cascade in NEXT_ACTION_BY_FAILURE.
u7 records the cascade actions as IMPLEMENTED in NEXT_ACTION_IMPLEMENTATION_STATUS.
"""
from __future__ import annotations
from src.phase_z2_failure_router import (
NEXT_ACTION_BY_FAILURE,
NEXT_ACTION_IMPLEMENTATION_STATUS,
classify_retry_failure,
enrich_retry_trace_with_failure_classification,
route_retry_failure,
)
def test_donor_slack_insufficient_routes_to_cross_zone_redistribute_implemented():
"""Stage 1 root cause — primary donor slack insufficient classifies as
donor_slack_insufficient and routes onto the deterministic salvage cascade
starting with cross_zone_redistribute (IMPLEMENTED per u7)."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {
"feasible": False,
"failure_reason": (
"primary donor 'bottom' slack 15px (aggregate 25px from 2 donor(s)) "
"< target_added_px 70px"
),
},
}
fc = classify_retry_failure(trace)
assert fc is not None
assert fc["failure_type"] == "donor_slack_insufficient"
nr = route_retry_failure("donor_slack_insufficient")
assert nr["next_proposed_action"] == "cross_zone_redistribute"
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
# enrichment composes both fields onto the trace
enrich_retry_trace_with_failure_classification(trace)
assert trace["failure_classification"]["failure_type"] == "donor_slack_insufficient"
assert trace["next_action_proposal"]["next_proposed_action"] == "cross_zone_redistribute"
def test_no_donor_candidates_routes_to_cross_zone_redistribute_implemented():
"""no_donor_candidates is the second cascade entry — also onto
cross_zone_redistribute per the locked mapping."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {"feasible": False, "failure_reason": "no donor candidates"},
}
fc = classify_retry_failure(trace)
assert fc["failure_type"] == "no_donor_candidates"
nr = route_retry_failure("no_donor_candidates")
assert nr["next_proposed_action"] == "cross_zone_redistribute"
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
"""u2 classifier inspects salvage_steps[-1]. u3 routes the 3 new failure
types through the deterministic cascade: cross_zone → glue → font_step →
layout_adjust. Verifies the locked NEXT_ACTION_BY_FAILURE table directly
and via the classifier path."""
# Direct mapping (u3 lock)
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
# Classifier path via salvage_steps[-1].action → failure_type → next action
cases = [
("cross_zone_redistribute", "cross_zone_redistribute_insufficient", "glue_compression"),
("glue_compression", "glue_absorption_insufficient", "font_step_compression"),
("font_step_compression", "font_step_insufficient", "layout_adjust"),
]
for action, expected_ftype, expected_next in cases:
trace = {
"retry_attempted": True,
"retry_passed": False,
"salvage_passed": False,
"salvage_steps": [
{"action": action, "passed": False, "failure_reason": "salvage failed"}
],
}
fc = classify_retry_failure(trace)
assert fc is not None, f"classifier returned None for action={action}"
assert fc["failure_type"] == expected_ftype
nr = route_retry_failure(fc["failure_type"])
assert nr["next_proposed_action"] == expected_next
def test_rerender_still_fails_preserved_routes_to_frame_reselect():
"""Pre-cascade behavior preserved: when plan was feasible and rerender ran
but visual still failed, classifier emits rerender_still_fails → frame_reselect."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {"feasible": True},
"rerender_attempted": True,
}
fc = classify_retry_failure(trace)
assert fc["failure_type"] == "rerender_still_fails"
nr = route_retry_failure("rerender_still_fails")
assert nr["next_proposed_action"] == "frame_reselect"
@@ -1,77 +0,0 @@
"""IMP-12 u13 — plan_font_step_compression tests.
Stage 2 contract (unit u13):
- feasible case (15.2 → 13 closes excess)
- infeasible (8px floor — FONT_SIZE_STEPS exhausted)
- text_metrics missing → defensive infeasible reason
u6 wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
Height savings per candidate font_size (Korean 1.6 line-height):
height_saved = (current_font_px * 1.6 - font_size * 1.6) * available_lines
Scope lock per feedback_phase_z_spacing_direction:
- apply_font_step_compression_css emits ONLY [data-zone-position="<pos>"] rule.
- No :root / body / .slide / .zone selectors permitted.
"""
from src.phase_z2_retry import (
apply_font_step_compression_css,
plan_font_step_compression,
)
def test_feasible_15_2_to_13_closes_excess() -> None:
"""current=15.2, excess=20, lines=10 → 14.0 saves 19.2 (insufficient);
13.0 saves 35.2 (>=20) → target_font_px=13.0. Emitted CSS scope-locked."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=20.0,
available_lines=10, chars_per_line=40, zone_position="bottom_l",
)
assert plan["action"] == "font_step_compression"
assert plan["zone_position"] == "bottom_l"
assert plan["current_font_px"] == 15.2
assert plan["excess_after_glue_px"] == 20.0
assert plan["available_lines"] == 10
assert plan["chars_per_line"] == 40
assert plan["font_floor_px"] == 8.0
assert plan["feasible"] is True
assert plan["target_font_px"] == 13.0
assert "failure_reason" not in plan
css = apply_font_step_compression_css(plan)
assert '[data-zone-position="bottom_l"]' in css
assert "font-size: 13.0px" in css
for forbidden in (":root", "body ", ".slide", ".zone"):
assert forbidden not in css, f"scope-lock violation: {forbidden!r} in css"
def test_infeasible_font_floor_exhausted() -> None:
"""current=15.2, excess=200, lines=10 — even 8.0px floor saves only 115.2,
so FONT_SIZE_STEPS is exhausted → feasible=False, classifier-matching reason."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=200.0,
available_lines=10, chars_per_line=40, zone_position="top",
)
assert plan["feasible"] is False
assert plan["target_font_px"] is None
assert plan["font_floor_px"] == 8.0
reason = plan["failure_reason"]
assert "font_step floor" in reason
assert "8.0px" in reason
assert "200.0px" in reason
assert "FONT_SIZE_STEPS exhausted" in reason
assert apply_font_step_compression_css(plan) == ""
def test_text_metrics_missing_defensive_infeasible() -> None:
"""available_lines=0 → guard fires before find_fitting_font_size;
failure_reason carries the text_metrics missing substring (classifier-friendly)."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=40.0,
available_lines=0, chars_per_line=40, zone_position="bottom_r",
)
assert plan["feasible"] is False
assert plan["target_font_px"] is None
assert "text_metrics missing" in plan["failure_reason"]
assert "available_lines/chars_per_line required" in plan["failure_reason"]
assert apply_font_step_compression_css(plan) == ""
@@ -1,87 +0,0 @@
"""IMP-12 u12 — plan_glue_compression tests.
Stage 2 contract (unit u12):
- feasible case asserts emitted CSS contains [data-zone-position=...]
selector and NO global :root / body / .slide rule (scope lock)
- insufficient case feasible=False with envelope reason
u5 wraps space_allocator.calculate_glue_absorption + compute_glue_css_overrides
in the Step-17 plan signature. Glue envelope per block_count (SPACING_GLUE):
absorption_max = block_gap.shrink * (block_count-1) # 12 * (n-1)
+ inner_gap.shrink * block_count # 8 * n
+ title_gap.shrink * block_count # 4 * n
+ container_padding.shrink * 2 # 8 * 2
block_count=3 → 12*2 + 8*3 + 4*3 + 8*2 = 24+24+12+16 = 76 px
block_count=1 → 12*0 + 8*1 + 4*1 + 8*2 = 0+8+4+16 = 28 px
CSS must be wrapped under [data-zone-position="<pos>"] only
(feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
"""
from __future__ import annotations
from src.phase_z2_retry import (
apply_glue_compression_css,
plan_glue_compression,
)
def test_feasible_case_emits_zone_scoped_css():
"""excess (40px) <= absorption_max (76px @ block_count=3) → feasible.
Emitted CSS must wrap overrides in [data-zone-position=...] selector and
contain none of the global selectors banned by feedback_phase_z_spacing_direction.
"""
plan = plan_glue_compression(
excess_px=40.0, block_count=3, zone_position="bottom_l",
)
assert plan["action"] == "glue_compression"
assert plan["zone_position"] == "bottom_l"
assert plan["feasible"] is True
assert plan["excess_px"] == 40.0
assert plan["block_count"] == 3
assert plan["absorption_max_px"] == 76.0
overrides = plan["overrides"]
assert overrides, "feasible plan must return non-empty overrides"
for key in ("--spacing-block", "--spacing-inner", "--container-padding"):
assert key in overrides, f"missing override key {key}"
css = apply_glue_compression_css(plan)
assert '[data-zone-position="bottom_l"]' in css
assert "--spacing-block:" in css
assert "--spacing-inner:" in css
assert "--container-padding:" in css
# Scope lock — no global rules permitted.
for forbidden in (":root", "body ", ".slide", ".zone"):
assert forbidden not in css, f"forbidden selector {forbidden!r} leaked into glue CSS"
def test_insufficient_envelope_feasible_false_with_reason():
"""excess (80px) > absorption_max (28px @ block_count=1) → infeasible.
failure_reason must surface the envelope shortage so the cascade router
(NEXT_ACTION_BY_FAILURE) can route onward to font_step_compression.
"""
plan = plan_glue_compression(
excess_px=80.0, block_count=1, zone_position="top",
)
assert plan["feasible"] is False
assert plan["absorption_max_px"] == 28.0
reason = plan["failure_reason"]
assert "glue envelope insufficient" in reason
assert "excess_px 80" in reason
assert "max absorption 28" in reason
# apply emits nothing when infeasible — no accidental CSS mutation on revert path.
assert apply_glue_compression_css(plan) == ""
def test_excess_non_positive_no_compression_needed():
"""excess_px <= 0 → defensive infeasible (no compression required)."""
plan = plan_glue_compression(
excess_px=0.0, block_count=3, zone_position="bottom_r",
)
assert plan["feasible"] is False
assert plan["overrides"] == {}
assert plan["absorption_max_px"] == 0.0
assert "no compression needed" in plan["failure_reason"]
assert apply_glue_compression_css(plan) == ""
@@ -1,147 +0,0 @@
"""IMP-12 u10 — plan_zone_ratio_retry multi-donor aggregation tests.
Stage 2 contract (unit u10):
- single-donor sufficient (regression — backward compat preserved)
- single insufficient + 2nd sufficient (multi-donor PASS path)
- aggregate insufficient (multi-donor FAIL path)
u1 extended plan_zone_ratio_retry from a single primary donor to greedy
slack-desc aggregation across all eligible sibling zones. The plan dict
now carries donors_used / aggregate_slack_used / aggregate_slack_available
while preserving donor_zone_position + donor_reduced_px for the failure
classifier substrings (router still keys off primary donor name).
"""
from __future__ import annotations
from src.phase_z2_retry import plan_zone_ratio_retry
_ROUTER_ACTIVE = {"router_active": True}
def _classification(target_pos: str, excess_y: float) -> dict:
return {
"classifications": [
{
"proposed_action": "zone_ratio_retry",
"zone_position": target_pos,
"inputs": {"excess_y": excess_y},
}
]
}
def _zone(position: str, height_px: int, min_height_px: int,
fit_status: str | None = "ok") -> dict:
return {
"position": position,
"height_px": height_px,
"min_height_px": min_height_px,
"composition_rationale": {
"capacity_fit": {"fit_status": fit_status},
},
}
def _overflow_clean(donor_positions: list[str]) -> dict:
return {
"zones": [
{"position": p, "overflowed": False, "clipped_inner": False}
for p in donor_positions
]
}
def test_single_donor_sufficient_regression():
"""One donor with abundant slack. Plan must remain feasible and the
legacy donor_zone_position / donor_reduced_px fields must reflect the
primary donor (router classifier substring stability)."""
debug_zones = [
_zone("top", height_px=200, min_height_px=180),
_zone("bottom", height_px=400, min_height_px=200), # slack=200
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["bottom"]),
fit_classification=_classification("top", excess_y=20.0),
router_decision=_ROUTER_ACTIVE,
)
assert plan is not None
assert plan["feasible"] is True
# target_added_px = ceil(20) + DEFAULT_SAFETY_MARGIN_PX(4) = 24
assert plan["target_added_px"] == 24
assert plan["donor_zone_position"] == "bottom"
assert plan["donor_reduced_px"] == 24
assert plan["donors_used"] == [
{"position": "bottom", "reduced_px": 24,
"slack_before": 200, "slack_after": 176}
]
assert plan["aggregate_slack_used"] == 24
assert plan["aggregate_slack_available"] == 200
assert plan["zones_after"]["top"] == 224
assert plan["zones_after"]["bottom"] == 376
def test_multi_donor_pass_primary_insufficient_secondary_covers():
"""Primary donor alone has insufficient slack but primary + secondary
aggregate covers target_added_px. Multi-donor greedy aggregation must
split the deficit across both donors in slack-desc order."""
debug_zones = [
_zone("top", height_px=300, min_height_px=200),
_zone("middle", height_px=250, min_height_px=200), # slack=50
_zone("bottom", height_px=240, min_height_px=200), # slack=40
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["middle", "bottom"]),
fit_classification=_classification("top", excess_y=66.0),
router_decision=_ROUTER_ACTIVE,
)
# target_added_px = ceil(66)+4 = 70. Primary (middle, slack=50) alone
# cannot cover, but middle(50)+bottom(40)=90 >= 70.
assert plan["feasible"] is True
assert plan["target_added_px"] == 70
assert plan["aggregate_slack_available"] == 90
assert plan["aggregate_slack_used"] == 70
assert plan["donor_zone_position"] == "middle" # primary
assert plan["donor_reduced_px"] == 50 # primary takes its full slack
assert [d["position"] for d in plan["donors_used"]] == ["middle", "bottom"]
assert plan["donors_used"][0]["reduced_px"] == 50
assert plan["donors_used"][1]["reduced_px"] == 20 # remainder
assert plan["zones_after"]["top"] == 370
assert plan["zones_after"]["middle"] == 200
assert plan["zones_after"]["bottom"] == 220
def test_multi_donor_fail_aggregate_insufficient():
"""All donors combined still cannot cover target_added_px. Plan must
be feasible=False with primary-donor substring preserved so the
failure_router classifier still routes through donor_slack_insufficient."""
debug_zones = [
_zone("top", height_px=300, min_height_px=200),
_zone("middle", height_px=210, min_height_px=200), # slack=10
_zone("bottom", height_px=215, min_height_px=200), # slack=15
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["middle", "bottom"]),
fit_classification=_classification("top", excess_y=66.0),
router_decision=_ROUTER_ACTIVE,
)
# target_added_px=70, aggregate=25 → fail
assert plan["feasible"] is False
assert plan["aggregate_slack_available"] == 25
assert plan["aggregate_slack_used"] == 0
assert plan["donors_used"] == []
# Primary = highest-slack donor = bottom (15)
assert plan["donor_zone_position"] == "bottom"
assert plan["donor_max_slack"] == 15
# Classifier substring stability: "donor", "slack", and "<" still present
reason = plan["failure_reason"]
assert "donor" in reason
assert "slack" in reason
assert "<" in reason
# zones unchanged on fail (revert-friendly)
assert plan["zones_after"]["top"] == 300
assert plan["zones_after"]["middle"] == 210
assert plan["zones_after"]["bottom"] == 215
@@ -1,196 +0,0 @@
"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection.
Tests Selenium-driven `<img>` aspect measurement added to ``run_overflow_check``:
* Fixture A — 200×100 image rendered at 200×100 → ``abs(delta) < tol``, no fail
reason, ``passed=True``.
* Fixture B — 200×100 image forced to render 200×200 → ``abs(delta) > 0.30``,
fail reason includes ``image aspect mismatch in zone--primary:``,
``passed=False``.
* Fixture C — ``<img>`` with no ``.zone`` ancestor (attached directly under
``.slide``) → event reports ``zone_position == "unknown"``.
Chromedriver resolution mirrors the pipeline's order
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH fallback). When no driver is
resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the
tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
import pytest
from src.phase_z2_pipeline import (
IMAGE_ASPECT_DELTA_TOL,
PROJECT_ROOT,
run_overflow_check,
)
PIL_Image = pytest.importorskip("PIL.Image", reason="Pillow required for fixture PNGs")
# ─── chromedriver skip / xfail guard ─────────────────────────────────
def _selenium_manager_resolvable() -> bool:
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
for driver auto-resolution. The test resolver must mirror that fallback
or PHASE_Z_REQUIRE_SELENIUM=1 produces spurious strict-XPASS failures on
machines where Selenium Manager can satisfy the pipeline at runtime.
"""
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as _Opts
except Exception:
return False
opts = _Opts()
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
try:
drv = webdriver.Chrome(options=opts)
except Exception:
return False
try:
drv.quit()
except Exception:
pass
return True
def _chromedriver_resolvable() -> bool:
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
if candidate.is_file():
return True
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
return True
return _selenium_manager_resolvable()
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
_DRIVER_AVAILABLE = _chromedriver_resolvable()
if not _DRIVER_AVAILABLE:
if _REQUIRE_SELENIUM:
pytestmark = pytest.mark.xfail(
strict=True,
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
)
else:
pytestmark = pytest.mark.skip(
reason=(
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
),
)
# ─── HTML / PNG fixture helpers ──────────────────────────────────────
_SLIDE_CSS = """
html, body { margin: 0; padding: 0; }
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
.zone { display: block; }
"""
def _write_png(path: Path, width: int, height: int, colour=(120, 160, 200)) -> Path:
img = PIL_Image.new("RGB", (width, height), colour)
img.save(path, format="PNG")
return path
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
html = (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<style>{_SLIDE_CSS}</style></head><body>"
'<div class="slide" data-page="1">'
f"{body_inner}"
"</div></body></html>"
)
path = tmp_path / name
path.write_text(html, encoding="utf-8")
return path
def _find_event(events, src_basename: str) -> dict:
for ev in events:
if Path(ev.get("src", "")).name == src_basename:
return ev
raise AssertionError(f"image_events missing entry for {src_basename}; got {events}")
# ─── tests ───────────────────────────────────────────────────────────
def test_image_no_distortion(tmp_path: Path) -> None:
"""Fixture A — 200×100 image rendered at native 200×100. delta ≈ 0."""
png = _write_png(tmp_path / "ok.png", 200, 100)
body = (
'<div class="zone" data-zone-position="primary" data-template-id="t_ok">'
f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
"</div>"
)
html_path = _write_slide_html(tmp_path, body, name="ok.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
assert result.get("image_events"), "image_events must be populated"
ev = _find_event(result["image_events"], png.name)
assert ev["zone_position"] == "primary"
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 100
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert image_fails == [], f"unexpected image fail_reasons: {image_fails}"
assert result["passed"] is True, result.get("fail_reasons")
def test_image_forced_distortion(tmp_path: Path) -> None:
"""Fixture B — 200×100 image forced to 200×200. delta > 0.30, fail emitted."""
png = _write_png(tmp_path / "bad.png", 200, 100, colour=(200, 80, 80))
body = (
'<div class="zone" data-zone-position="primary" data-template-id="t_bad">'
f'<img src="{png.name}" style="width:200px;height:200px;display:block">'
"</div>"
)
html_path = _write_slide_html(tmp_path, body, name="bad.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
ev = _find_event(result["image_events"], png.name)
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 200
assert ev["delta"] is not None and abs(ev["delta"]) > 0.30
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert len(image_fails) == 1, f"expected one image fail_reason, got: {image_fails}"
msg = image_fails[0]
assert msg.startswith("image aspect mismatch in zone--primary:"), msg
assert "natural=2.000" in msg and "rendered=1.000" in msg
assert f"src={png.name}" in msg or png.name in msg
assert result["passed"] is False
def test_image_no_zone_ancestor(tmp_path: Path) -> None:
"""Fixture C — <img> attached directly under .slide → zone_position == 'unknown'."""
png = _write_png(tmp_path / "loose.png", 200, 100, colour=(80, 200, 120))
body = f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
html_path = _write_slide_html(tmp_path, body, name="loose.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
ev = _find_event(result["image_events"], png.name)
assert ev["zone_position"] == "unknown"
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert image_fails == []
@@ -1,248 +0,0 @@
"""IMP-12 u15 — End-to-end test of `_attempt_salvage_chain` (Step 17 deterministic salvage cascade).
Three Stage 2 cases against `src.phase_z2_pipeline._attempt_salvage_chain`:
(a) zone_ratio fail + cross_zone pass → final.html promoted, salvage_passed=True
(b) cross_zone fail + glue pass → 2nd cascade step promoted, salvage_passed=True
(c) all 3 fail → (b)-revert preserved, original final.html intact, salvage_passed=False
`render_slide` and `run_overflow_check` are monkey-patched so the test stays deterministic
(no Selenium / Jinja2 template files). The patches only stand in for the rendering / overflow
oracles — the planners (`plan_cross_zone_redistribute`, `plan_glue_compression`,
`plan_font_step_compression`) and the cascade router (`route_retry_failure` /
`SALVAGE_FAIL_BY_ACTION`) all run unmocked.
"""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
import pytest
import src.phase_z2_pipeline as _pz_pipeline
from src.fit_verifier import FitAnalysis, RoleFit
from src.phase_z2_pipeline import _attempt_salvage_chain
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
@pytest.fixture
def project_tmp(tmp_path_factory):
"""Temp dir under PROJECT_ROOT so _attempt_salvage_chain can call
candidate_path.relative_to(PROJECT_ROOT) without ValueError on a
cross-drive system tmp path (pytest's default tmp_path is under
%LOCALAPPDATA% on Windows, which lives on a different drive from
the project root in this repo)."""
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
base.mkdir(parents=True, exist_ok=True)
d = Path(tempfile.mkdtemp(prefix="u15_salvage_", dir=str(base)))
try:
yield d
finally:
shutil.rmtree(d, ignore_errors=True)
_LAYOUT_CSS_GATE_PASS = {
"areas": '"top" "bottom"',
"cols": "1fr",
"rows": "1fr 1fr",
"heights_px": [300, 290],
"widths_px": [1180],
"ratios": [0.508, 0.491],
"width_ratios": [1.0],
"dynamic_rows": True,
"dynamic_cols": False,
}
def _patch_render(monkeypatch):
"""Stub render_slide → deterministic HTML envelope so the cascade does not
need real Jinja2 templates. Returns a counter so tests can assert how many
times it was invoked (one per CSS-feasible cascade step)."""
counter = {"n": 0}
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
counter["n"] += 1
return (
f"<html><head><meta charset='utf-8'></head>"
f"<body><div data-slide-title='{slide_title}'></div></body></html>"
)
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
return counter
def _kwargs(*, run_dir: Path, out_path: Path, cascade_inputs: dict,
initial_failure_type: str = "donor_slack_insufficient") -> dict:
return {
"run_dir": run_dir,
"out_path": out_path,
"slide_title": "u15-test",
"slide_footer": None,
"zones_data": [],
"layout_preset": "horizontal-2",
"layout_css": _LAYOUT_CSS_GATE_PASS,
"cascade_inputs": cascade_inputs,
"initial_failure_type": initial_failure_type,
"gap_px": 14,
}
def test_case_a_cross_zone_passes_final_html_promoted(project_tmp, monkeypatch):
"""(a) cross_zone_redistribute is feasible + run_overflow_check returns
passed=True → out_path overwritten with the cross_zone candidate HTML and
salvage_passed=True after the very first cascade iteration."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
# Multi-role same-zone FitAnalysis: top +30 deficit, bottom_l -50 surplus.
fit_analysis = FitAnalysis(roles={
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
"bottom_l": RoleFit(role="bottom_l", allocated_px=300, shortfall_px=-50.0),
})
containers = {
"top": {"zone": "slide_body", "height_px": 200},
"bottom_l": {"zone": "slide_body", "height_px": 300},
}
cascade_inputs = {
"fit_analysis": fit_analysis,
"containers": containers,
"min_margin_px": 10,
"excess_px": 30.0, "excess_after_glue_px": 30.0,
"block_count": 3, "zone_position": "top",
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
_patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 1
step0 = trace["salvage_steps"][0]
assert step0["action"] == "cross_zone_redistribute"
assert step0["passed"] is True
assert step0["plan"]["feasible"] is True
assert step0["css_override"] and '[data-role=' in step0["css_override"]
# out_path was overwritten with the salvage candidate.
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
assert "u15-test" in promoted
def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkeypatch):
"""(b) cross_zone is infeasible (single-role zone) → glue_compression CSS
emitted + run_overflow_check passes → out_path overwritten with the glue
candidate (2nd cascade step). salvage_passed=True; salvage_steps[0]
records the infeasible cross_zone attempt."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
# Single-role zone → fit_verifier.redistribute returns can_redistribute=False
# (peer required, none present).
fit_analysis = FitAnalysis(roles={
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
})
containers = {"top": {"zone": "slide_body", "height_px": 200}}
# Glue envelope at block_count=3 = 12*(3-1)+8*3+4*3+8*2 = 76 px → 40 px is feasible.
cascade_inputs = {
"fit_analysis": fit_analysis,
"containers": containers,
"min_margin_px": 10,
"excess_px": 40.0, "excess_after_glue_px": 40.0,
"block_count": 3, "zone_position": "bottom_l",
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
render_counter = _patch_render(monkeypatch)
# cross_zone is infeasible → no CSS → no rerender / no overflow call. Glue is
# feasible → exactly one rerender + overflow call → return passed=True.
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 2
s0 = trace["salvage_steps"][0]
assert s0["action"] == "cross_zone_redistribute"
assert s0["passed"] is False
assert s0["plan"]["feasible"] is False
assert s0["css_override"] is None
assert "single-role zone" in (s0["plan"].get("failure_reason") or "")
s1 = trace["salvage_steps"][1]
assert s1["action"] == "glue_compression"
assert s1["passed"] is True
assert s1["plan"]["feasible"] is True
assert s1["css_override"] and '[data-zone-position="bottom_l"]' in s1["css_override"]
# render_slide was invoked exactly once (only the glue branch emitted CSS).
assert render_counter["n"] == 1
# out_path was overwritten with the glue candidate.
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
"""(c) All three cascade actions are infeasible (no CSS emitted by any
planner) → run_overflow_check is never invoked, salvage_passed=False,
salvage_steps has three failed entries, and out_path is unchanged
(original final.html intact — (b)-revert preserved)."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
cascade_inputs = {
# cross_zone: fit_analysis missing → plan returns feasible=False with reason
# `cascade_inputs.fit_analysis missing` (see _attempt_salvage_chain branch).
"fit_analysis": None,
"containers": {},
"min_margin_px": 10,
# glue: excess_px (200) > envelope max at block_count=1 (28) → infeasible.
"excess_px": 200.0, "excess_after_glue_px": 200.0,
"block_count": 1, "zone_position": "top",
# font_step: current_font_px=15.2 cannot absorb 200px even at 8px floor
# → find_fitting_font_size returns None → feasible=False.
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
render_counter = _patch_render(monkeypatch)
# Guard: if run_overflow_check is ever called, the test fails loudly.
def _must_not_call(_p): # pragma: no cover — intentional sentinel
raise AssertionError("run_overflow_check must not run when no CSS is emitted")
monkeypatch.setattr(_pz_pipeline, "run_overflow_check", _must_not_call)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 3
actions = [s["action"] for s in trace["salvage_steps"]]
assert actions == [
"cross_zone_redistribute",
"glue_compression",
"font_step_compression",
]
for step in trace["salvage_steps"]:
assert step["passed"] is False
assert step["css_override"] is None
assert step["failure_reason"]
# No CSS emitted anywhere → no render_slide calls either.
assert render_counter["n"] == 0
# (b) revert: out_path is untouched.
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
@@ -1,69 +0,0 @@
"""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")
-50
View File
@@ -1,50 +0,0 @@
"""IMP-13 u7 smoke — discovery, source invariants, dry-run, idempotency, manifest schema."""
from __future__ import annotations
import json, os, re, sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_PATH = REPO_ROOT / "scripts" / "generate_frame_previews.py"
sys.path.insert(0, str(SCRIPT_PATH.parent))
import generate_frame_previews as gfp # noqa: E402
def _fixture(root: Path) -> Path:
blocks = root / "blocks"
(blocks / "FRAME_A").mkdir(parents=True)
(blocks / "FRAME_A" / "index.html").write_text("<html><body class=slide></body></html>", encoding="utf-8")
(blocks / "FRAME_A" / "preview.png").write_bytes(b"\x89PNG\r\n\x1a\n")
(blocks / "FRAME_B").mkdir()
(blocks / "ORPHAN").mkdir()
(blocks / "ORPHAN" / "preview.png").write_bytes(b"x")
return blocks
def test_discover_counts(tmp_path: Path) -> None:
rows = gfp.discover(_fixture(tmp_path))
assert [r.frame_id for r in rows] == ["FRAME_A", "FRAME_B", "ORPHAN"]
assert sum(r.has_index for r in rows) == 1 and sum(r.has_preview for r in rows) == 2
def test_source_invariants() -> None:
src = SCRIPT_PATH.read_text(encoding="utf-8")
for t in ("anthropic", "openai", "jinja", "phase_z2", "slide_measurer"): assert t not in src, t
for lit in ("1280", "720", "1400", "900"): assert not re.search(rf"(?<!\d){lit}(?!\d)", src), lit
def test_dry_run_prints_counts(tmp_path: Path, capsys) -> None:
rc = gfp.main(["--blocks-dir", str(_fixture(tmp_path)), "--manifest", str(tmp_path / "m.json"), "--dry-run"])
assert rc == 0 and "discovered: total=3 with_index_html=1 with_preview_png=2" in capsys.readouterr().out
def test_idempotency_unchanged(tmp_path: Path) -> None:
row = gfp.discover(_fixture(tmp_path))[0]
mt = row.index_html_path.stat().st_mtime
os.utime(row.preview_png_path, (mt + 1, mt + 1))
sha = gfp._sha256_file(row.index_html_path)
assert gfp.is_unchanged(row, {"index_sha256": sha}) is True
assert gfp.is_unchanged(row, {"index_sha256": "x"}) is False
assert gfp.is_unchanged(row, None) is False
def test_manifest_schema(tmp_path: Path) -> None:
blocks = tmp_path / "blocks"; (blocks / "F").mkdir(parents=True); (blocks / "F" / "preview.png").write_bytes(b"x")
mf = tmp_path / "m.json"
assert gfp.main(["--blocks-dir", str(blocks), "--manifest", str(mf)]) == 0
data = json.loads(mf.read_text(encoding="utf-8"))
assert set(data) >= {"schema", "generated_at", "blocks_dir", "summary", "frames"} and data["schema"] == 1
assert set(data["summary"]) >= {"total", "renderable", "missing_index_html", "orphan", "rendered", "skipped_unchanged", "error"} and data["summary"]["orphan"] == 1 and data["frames"]["F"]["status"] == "orphan"
@@ -1,169 +0,0 @@
"""IMP-11 D-2 (u1) — Step 9 v4_all_judgments[] min_height_px field tests.
u1 contract:
Each v4_all_judgments[] entry MUST expose `min_height_px` sourced from
catalog `frame_contracts[template_id].visual_hints.min_height_px`
(logical 1280×720 px), with `None` fallback when contract is unregistered.
A single `get_contract(c.template_id)` lookup binds both
`catalog_registered` and `min_height_px` (no double-lookup cost).
Production code = inline list builder in `run_phase_z2_mvp1`
(`src/phase_z2_pipeline.py`, near v4_all_for_unit loop). These tests follow
the same source-string + catalog-shape guard pattern as the existing
`test_step9_production_emits_candidate_evidence_and_alias` in
`tests/test_phase_z2_v4_fallback.py`, kept until a helper is extracted.
"""
from __future__ import annotations
import inspect
from src import phase_z2_pipeline
from phase_z2_mapper import get_contract, load_frame_contracts
# ─── Case 1 : u1 production-source guard ────────────────────────────────────
def test_v4_all_judgments_emits_min_height_px_with_none_fallback():
"""Source guard — single get_contract bound to `_contract`, then both
`catalog_registered` and `min_height_px` derived from that binding.
`min_height_px` uses the `(_contract or {})` chain so unregistered
contracts propagate `None` (frontend tolerates undefined).
"""
source = inspect.getsource(phase_z2_pipeline)
# u1 marker present (locates the builder)
assert "IMP-11 D-2 (u1)" in source
# Single get_contract lookup bound to local var
assert "_contract = get_contract(c.template_id)" in source
# catalog_registered reuses the local binding (no second lookup)
assert '"catalog_registered": _contract is not None' in source
# min_height_px source = visual_hints chain; None when contract is None
assert (
'"min_height_px": (_contract or {})'
'.get("visual_hints", {})'
'.get("min_height_px")'
) in source
# v4_all_judgments wires the new builder list
assert '"v4_all_judgments": v4_all_judgments_list' in source
# ─── Case 2 : additive guarantee — existing 7 fields preserved ──────────────
def test_v4_all_judgments_preserves_existing_fields():
"""u1 is additive only — the existing 7 keys must remain in the per-entry
dict alongside the new `min_height_px`.
"""
source = inspect.getsource(phase_z2_pipeline)
builder_start = source.find("IMP-11 D-2 (u1)")
assert builder_start != -1
builder_end = source.find("application_plan_units.append", builder_start)
assert builder_end != -1
builder = source[builder_start:builder_end]
for field in (
'"template_id": c.template_id',
'"frame_id": c.frame_id',
'"frame_number": c.frame_number',
'"v4_rank": c.v4_rank',
'"confidence": c.confidence',
'"label": c.label',
'"catalog_registered": _contract is not None',
'"min_height_px":',
):
assert field in builder, f"missing field in u1 builder: {field!r}"
# ─── Case 3 : catalog reality — visual_hints.min_height_px shape is real ────
def test_catalog_visual_hints_min_height_px_path_is_real():
"""The source-string guard depends on the actual catalog shape having
`visual_hints.min_height_px` as a positive int on registered contracts
whose `visual_hints` block declares it. Verify against the real
`frame_contracts.yaml` so a future catalog schema change cannot silently
invalidate the `.get("visual_hints", {}).get("min_height_px")` chain.
"""
load_frame_contracts()
# Real registered template_ids that ship with visual_hints.min_height_px
# (verified via load_frame_contracts() — see frame_contracts.yaml).
sample_template_ids = (
"three_parallel_requirements",
"process_product_two_way",
"construction_goals_three_circle_intersection",
"bim_dx_comparison_table",
)
found = 0
for tid in sample_template_ids:
contract = get_contract(tid)
if contract is None:
continue # tolerate catalog rename — at least one must remain
# The exact .get chain used by the u1 builder
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
assert isinstance(min_h, int), (
f"{tid}: visual_hints.min_height_px must be int, "
f"got {type(min_h).__name__}={min_h!r}"
)
assert min_h > 0, f"{tid}: min_height_px must be positive, got {min_h}"
found += 1
assert found > 0, (
"no sample registered contract present — catalog audit drift; "
"update sample_template_ids to match current frame_contracts.yaml"
)
def test_registered_contract_without_min_height_px_propagates_none():
"""Registered contract whose `visual_hints` block omits `min_height_px`
(or sets it to `null`) must also propagate `None` through the u1 chain.
Real example in current catalog: `bim_issues_quadrant_four`.
"""
load_frame_contracts()
tid = "bim_issues_quadrant_four"
contract = get_contract(tid)
if contract is None:
import pytest # noqa: PLC0415 — runtime skip only when catalog drifts
pytest.skip(f"sample template {tid!r} no longer registered")
# Exact chain used by u1 builder
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
assert min_h is None, (
f"{tid}: expected None when visual_hints.min_height_px is absent/null, "
f"got {min_h!r} — chain semantics changed"
)
# catalog_registered must still be True (additive, independent of value)
assert (contract is not None) is True
# ─── Case 4 : None propagation for unregistered template_id ─────────────────
def test_unregistered_template_id_propagates_none():
"""When `get_contract(template_id)` returns `None`, the u1 chain
`(_contract or {}).get("visual_hints", {}).get("min_height_px")` must
yield `None` (frontend tolerates undefined; no KeyError).
"""
load_frame_contracts()
# Synthetic template_id guaranteed not to be in the catalog
unregistered = "MOCK_template_unregistered_for_u1_test"
assert get_contract(unregistered) is None, (
"test precondition broken — synthetic template_id leaked into catalog"
)
# Replicate the u1 chain exactly
_contract = get_contract(unregistered)
min_height = (_contract or {}).get("visual_hints", {}).get("min_height_px")
catalog_registered = _contract is not None
assert min_height is None
assert catalog_registered is False