Compare commits

Author SHA1 Message Date
Kyeongmin 4e6259905e On main: pre-Stage3 IMP-09 B-4 — out-of-scope pre-existing modifications (preserve for later) 2026-05-17 04:28:05 +09:00
Kyeongmin c537bf26ac index on main: 201099e feat(IMP-09): PR 1 — col-axis solver + per-zone geometry mapper + retry gate 2026-05-17 04:28:05 +09:00
KyeongminandClaude Opus 4.7 201099e53b feat(IMP-09): PR 1 — col-axis solver + per-zone geometry mapper + retry gate
Stage 3 round 4 lock implementation: extend build_layout_css beyond
the horizontal-2-only dynamic path. Every layout_css return now
carries length-locked col-axis keys (widths_px, width_ratios,
dynamic_cols) matching the parsed css_areas grid (R rows, C cols),
so 2-D layouts (T / 2x2 in PR 2) and the unified
_compute_per_zone_geometry mapper can plug in without further
contract churn.

PR 1 scope:
  - _parse_css_areas + _parse_fr_string + _compute_per_zone_geometry
    (unified — 1-D and 2-D from the same code path)
  - compute_zone_layout_cols (vertical-2 weight-only solver)
  - _build_fr_default / _build_rows_dynamic / _build_cols_dynamic
    (populate widths_px/heights_px on every return path)
  - build_layout_css override branch keeps the warn-and-fallthrough
    legacy for unsupported presets (PR 2 promotes to strict raise)
  - retry gate in _attempt_zone_ratio_retry skips when dynamic_cols=True
    or dynamic_rows=False, with explicit retry_skipped_reason
  - Step 8 artifact gains zone_widths_px_planned /
    zone_col_ratios_planned (top-level) + zone_width_px_planned /
    zone_col_ratio_planned (per-zone)
  - debug_zones width injection via _compute_per_zone_geometry
    (replaces the legacy row-only zip)

Tests: tests/phase_z2/ — 47 new cases (parse / fr-string / cols solver /
per-zone geometry / build_layout_css contract / retry gate +
6 build_layout_css YAML fixtures + 3 retry_gate fixtures).

Verification: python -m pytest -q tests = 89 passed (was 42).
horizontal-2 grid CSS strings (areas/cols/rows) byte-identical to
legacy; only additive col-axis keys are introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:03:23 +09:00
KyeongminandClaude Opus 4.7 8f6cffc2a7 fix(IMP-08): Stage 5 R2 — aligner force-drill on sub-id override targets
Codex #1 (Stage 5) reproduced a smoke regression on the actual checkout :
when V4 carries the parent exact key (e.g., `04-2`) AND the drag/drop
override targets a sub-id (`primary=04-2-sub-1`), the aligner kept the
parent at parent granularity and emit `['04-1', '04-2']`, so the override
flag failed with `unknown section_id(s) ['04-2-sub-1']`.

Fix : `align_sections_to_v4_granularity` gains an optional
`override_target_section_ids` keyword. From each canonical
`${parent}-sub-N` target it derives the parent id and adds it to a
`force_drill_parents` set. Sections in that set are drilled into
sub-sections regardless of whether V4 carries the parent exact key.
Top-level override targets (no derived parent) do not trigger
force-drill, so backward-compat is preserved for parent-granularity
overrides.

The call site in `run_phase_z2_mvp1` collects sub-ids from
`override_section_assignments` and forwards them to the aligner.

Generalization (RULE 0) :
- Trigger is the override schema (`X-sub-N`), not a specific MDX / section /
  frame id. Applies to all 32-frame MDX uniformly.
- Decision is deterministic on the override target shape, independent of
  V4 yaml content.
- Default (no override) path is unchanged byte-for-byte.

Side fixes (forward-only RULE 1 cleanup, no history rewrite) :
- `align_sections_to_v4_granularity` docstring rewritten in English
  (overwrites the Korean docstring committed in 5191aca).
- Step 9 diagnostic comment quoted-string rewritten in English
  (overwrites `"V4 entry 없음"` committed in a422d72).

Tests : 3 new cases in `test_phase_z2_subsection_schema.py` —
`test_align_parent_v4_exact_keeps_section_when_no_override_targets_sub`
(backward-compat axis), `test_align_force_drills_when_override_targets_sub_id_with_parent_in_v4`
(blocker regression), `test_align_top_level_override_target_does_not_force_drill_other_sections`
(force-drill scope guard). Pytest scope-qualified result :
`test_phase_z2_subsection_schema.py` + `_section_assignment_override.py` +
`_v4_fallback.py` = 40 / 40 PASS.

Smoke (axis = sub-id override -> aligner -> assignment plan, both V4 yaml
shapes) :
- HEAD V4 yaml (`04-1`, `04-2.1`, `04-2.2` only) :
  `--override-section-assignment primary=04-2-sub-1` ->
  `aligned_section_ids=['04-1', '04-2-sub-1', '04-2-sub-2']`,
  `plan[0].assignment_source='cli_override'`,
  `plan[0].source_section_ids=['04-2-sub-1']`.
- V4 yaml with `04-2` exact key (Codex's stress case) : identical
  aligned output and identical assignment plan.

Downstream `composition_planner` abort
(`phase_z_status_not_allowed:extract_matched_zone`) is IMP-05 territory,
unchanged in both shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:28:46 +09:00
30 changed files with 4023 additions and 952 deletions
+2
View File
@@ -7,6 +7,8 @@ __pycache__/
dist/
build/
.venv/
.orchestrator/
tmpim*.json/
node_modules/
data/
+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/>
+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
+429 -60
View File
@@ -41,6 +41,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
from phase_z2_composition import (
LAYOUT_PRESETS,
CompositionUnit,
derive_parent_id,
plan_composition,
select_display_strategy_candidates,
select_layout_candidates,
@@ -372,47 +373,80 @@ def load_v4_result() -> dict:
return yaml.safe_load(V4_RESULT_PATH.read_text(encoding="utf-8"))
def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> list[MdxSection]:
"""V4 section granularity 에 맞춰 sections 조정.
def align_sections_to_v4_granularity(
sections: list[MdxSection],
v4: dict,
*,
override_target_section_ids: Optional[list[str]] = None,
) -> list[MdxSection]:
"""Align MDX sections to canonical sub-section granularity.
IMP-08 B-3 : canonical sub-section id ``${section_id}-sub-${ordinal}``
(예 : ``04-2-sub-1``) 를 emit 하고, legacy V4 키 (``04-2.1``) 는
``v4_alias_keys`` 로 보존하여 ``_resolve_v4_section_key`` 가 alias 경로로
매칭한다. canonical ordinal id 는 frontend drag/drop override 와 동일
schema (`section_id-sub-N`).
Default behaviour (V4-driven granularity, backward compatible) :
- V4 has section_id exact key -> keep section unchanged (parent
granularity rendering, parent-level V4 evidence applies).
- V4 missing + H3 sub-sections -> drill into sub-sections, emit
canonical ids ``${section_id}-sub-${ordinal}`` with optional
decimal alias for legacy V4 keys (e.g. ``04-2.1``).
- V4 missing + no H3 -> pass through (downstream V4 lookup
will naturally abort with no_v4_section).
N-R5 alias guard : heading_number 가 decimal (``2.1``) 일 때만 alias
emit. integer-only (``1``) / non-numeric heading 은 alias 0 — sibling
parent V4 evidence 로 잘못 promote 되는 collision 방지 (RULE 0).
IMP-08 B-3 / Stage 5 R2 blocker-fix — ``override_target_section_ids``
is the list of section ids that drag/drop override CLI flags target.
When any override target matches ``${section_id}-sub-N`` for a section
whose parent is otherwise V4-aligned, that section is force-drilled so
sub-section ids become addressable. This keeps the default rendering
path on V4 granularity while making drag/drop deterministic regardless
of whether V4 carries a parent exact key.
각 section 에 대해 :
- V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭)
- V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill
- V4 에 키 없고 ### 도 없음 → 원본 그대로 (V4 lookup 단계에서 자연스럽게 abort)
Each drilled sub-section carries :
- heading_number : decimal "2.1" / integer "1" / None (bare H3 title).
- v4_alias_keys : legacy V4 keys to try when the canonical ordinal
id misses. Populated only when ``heading_number`` matches the
decimal pattern ``\\d+\\.\\d+`` (N-R5 guard) — integer-only or
bare H3 produces no alias to avoid sibling-parent V4 collisions.
설계 원칙 :
- parser (parse_mdx) = MDX 만 앎 (V4 무관)
- aligner (이 함수) = V4 키 기준 granularity 결정
- runtime parser 가 matching artifact 의 granularity 를 *따라가는* 구조
Design boundary :
- parser (``parse_mdx``) = MDX-only knowledge (V4-agnostic).
- aligner (this function) = canonical sub-id schema, MDX-driven on
force_drill, V4-driven otherwise.
- resolver (``_resolve_v4_section_key``) = exact > alias > None,
never auto-promotes to parent/sibling (axis 7 hybrid lock).
"""
v4_keys = set(v4.get("mdx_sections", {}).keys())
# Build the set of parent ids whose sub-ids are explicitly targeted by
# an override. These sections must be drilled even if V4 also carries
# the parent key exactly. Parents derived from canonical "X-sub-N" ids
# only — non-sub ids (top-level overrides) do not trigger drilling.
force_drill_parents: set[str] = set()
if override_target_section_ids:
for sid in override_target_section_ids:
parent = derive_parent_id(sid)
if parent and sid != parent:
force_drill_parents.add(parent)
aligned: list[MdxSection] = []
# IMP-08 B-3 : capture optional heading-number prefix (decimal "2.1" or
# integer "1") + heading title. None group = bare "### Title".
# Capture optional heading-number prefix (decimal "2.1" or integer "1")
# plus the heading title. None group = bare "### Title".
sub_pattern = re.compile(
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
)
decimal_re = re.compile(r"\d+\.\d+")
for section in sections:
if section.section_id in v4_keys:
force_drill = section.section_id in force_drill_parents
if section.section_id in v4_keys and not force_drill:
# V4 carries this section exactly and no override targets a
# sub-id under it: keep parent granularity (backward compat).
aligned.append(section)
continue
sub_matches = list(sub_pattern.finditer(section.raw_content))
if not sub_matches:
aligned.append(section) # drill 불가, V4 lookup 에서 abort 됨
# No H3 sub-sections: cannot drill. Pass section through;
# downstream V4 lookup aborts with no_v4_section when needed.
aligned.append(section)
continue
mdx_id = section.section_id.split("-")[0] # e.g., "04"
@@ -800,6 +834,285 @@ def compute_zone_layout(zones_data: list[dict],
}
# ─── IMP-09 PR 1 helpers (8-preset layout vocabulary) ────────────────
# Catalog css_areas / css_cols / css_rows parsing + per-zone aggregation
# + col-axis solver. Symmetric counterparts to compute_zone_layout (row-axis).
def _parse_css_areas(css_areas: str) -> tuple[list[list[str]], list[str]]:
"""Parse CSS grid-template-areas string into (row x col) cell grid.
Input : '"top top" "bottom-left bottom-right"'
Output : (
[["top", "top"], ["bottom-left", "bottom-right"]],
["top", "bottom-left", "bottom-right"],
)
Raises ValueError on empty input, missing quotes, empty row, or
non-rectangular layout (rows with mismatched column counts).
"""
rows: list[list[str]] = []
seen: list[str] = []
quoted = re.findall(r'"([^"]+)"', css_areas)
if not quoted:
raise ValueError(
f"_parse_css_areas: no quoted row strings found in {css_areas!r}"
)
for row_str in quoted:
tokens = row_str.split()
if not tokens:
raise ValueError(
f"_parse_css_areas: empty row in {css_areas!r}"
)
rows.append(tokens)
for token in tokens:
if token not in seen:
seen.append(token)
col_counts = {len(r) for r in rows}
if len(col_counts) > 1:
raise ValueError(
f"_parse_css_areas: non-rectangular grid, row column counts = "
f"{col_counts} in {css_areas!r}"
)
return rows, seen
def _parse_fr_string(spec: str, total: int) -> list[int]:
"""Parse '1fr' / '1fr 1fr' / 'Nfr Mfr' into integer px lengths.
Catalog presets (templates/phase_z2/layouts/layouts.yaml) only use
1fr-only specs; mixed px/fr is out of scope. Raises ValueError on
non-fr tokens or zero total.
"""
fractions: list[float] = []
for token in spec.split():
m = re.fullmatch(r"(\d+(?:\.\d+)?)fr", token)
if not m:
raise ValueError(
f"_parse_fr_string: non-fr token {token!r} in {spec!r}"
)
fractions.append(float(m.group(1)))
if not fractions:
raise ValueError(f"_parse_fr_string: empty spec {spec!r}")
total_fr = sum(fractions)
if total_fr <= 0:
raise ValueError(f"_parse_fr_string: total fr = 0 in {spec!r}")
sizes = [int(round(total * (f / total_fr))) for f in fractions]
sizes[-1] += total - sum(sizes)
return sizes
def compute_zone_layout_cols(zones_data: list[dict],
total_width: int = SLIDE_BODY_WIDTH,
gap: int = GRID_GAP) -> dict:
"""Per-zone column width allocation — weight-only distribution.
Symmetric counterpart of compute_zone_layout for the column axis.
No min_width_px contract exists in frame_contracts.yaml (verified
empty as of IMP-09), so column allocation is purely content_weight
score based.
"""
n = len(zones_data)
if n == 0:
return {"widths_px": [], "width_ratios": [], "zones": []}
available = total_width - gap * (n - 1)
weights = [z["content_weight"]["score"] for z in zones_data]
total_w = sum(weights)
if total_w <= 0:
# Zero-weight guard (override-empty zone where score=0).
widths_px = [available // n] * n
widths_px[-1] += available - sum(widths_px)
weight_shares = [round(1.0 / n, 3)] * n
else:
widths_px = [
int(round(available * (w / total_w))) for w in weights
]
diff = available - sum(widths_px)
if diff != 0:
widths_px[-1] += diff
weight_shares = [round(w / total_w, 3) for w in weights]
width_ratios = [round(w / total_width, 3) for w in widths_px]
return {
"computation": "content_weight_distribution_cols",
"slide_body_width": total_width,
"gap": gap,
"available_after_gap": available,
"content_weights": [
{"position": z["position"],
"template_id": z["template_id"],
"score": w}
for z, w in zip(zones_data, weights)
],
"weight_shares": weight_shares,
"widths_px": widths_px,
"width_ratios": width_ratios,
}
def _compute_per_zone_geometry(
layout_css: dict,
debug_zones: list[dict],
gap: int = GRID_GAP,
) -> list[dict]:
"""Aggregate grid-track sizes into per-zone dimensions for ALL layouts.
Parses layout_css["areas"] (catalog css_areas) into an R x C cell
grid, then for each zone in debug_zones sums the heights_px of its
occupied rows and widths_px of its occupied columns, including the
inter-track gap absorbed by a spanning zone.
Length contract: layout_css["heights_px"] MUST have length R, and
layout_css["widths_px"] MUST have length C. Mismatch raises
ValueError because that indicates a broken build path, not a
runtime input issue.
"""
rows_grid, _ = _parse_css_areas(layout_css["areas"])
R = len(rows_grid)
C = len(rows_grid[0])
heights_px = layout_css.get("heights_px") or []
widths_px = layout_css.get("widths_px") or []
if len(heights_px) != R:
raise ValueError(
f"_compute_per_zone_geometry: heights_px length "
f"{len(heights_px)} != grid rows R={R} "
f"(css_areas={layout_css.get('areas')!r})"
)
if len(widths_px) != C:
raise ValueError(
f"_compute_per_zone_geometry: widths_px length "
f"{len(widths_px)} != grid cols C={C} "
f"(css_areas={layout_css.get('areas')!r})"
)
per_zone: list[dict] = []
for dz in debug_zones:
pos = dz["position"]
occupied_rows = sorted(
{r for r, row in enumerate(rows_grid) if pos in row}
)
occupied_cols = sorted(
{c for r, row in enumerate(rows_grid)
for c, tok in enumerate(row) if tok == pos}
)
if not occupied_rows or not occupied_cols:
raise ValueError(
f"_compute_per_zone_geometry: zone position {pos!r} "
f"not present in css_areas {rows_grid}"
)
zh = (
sum(heights_px[r] for r in occupied_rows)
+ gap * (len(occupied_rows) - 1)
)
zw = (
sum(widths_px[c] for c in occupied_cols)
+ gap * (len(occupied_cols) - 1)
)
per_zone.append({
"position": pos,
"zone_height_px": zh,
"zone_width_px": zw,
"zone_height_ratio": round(zh / SLIDE_BODY_HEIGHT, 3),
"zone_width_ratio": round(zw / SLIDE_BODY_WIDTH, 3),
})
return per_zone
def _build_fr_default(preset: dict) -> dict:
"""fr-default sink — populate widths_px / heights_px from catalog fr ratios.
Replaces the legacy empty-array sink so that downstream consumers
(Step 7/8 trace, _compute_per_zone_geometry) always receive
length-locked arrays matching the catalog grid dimensions.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
avail_h = SLIDE_BODY_HEIGHT - GRID_GAP * (R - 1)
avail_w = SLIDE_BODY_WIDTH - GRID_GAP * (C - 1)
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": preset["css_rows"],
"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": "fr_default_from_preset",
"dynamic_rows": False,
"dynamic_cols": False,
"raw_zone_layout": None,
}
def _build_rows_dynamic(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""horizontal-2 path — dynamic row heights, static fr column widths.
Preserves the legacy compute_zone_layout output (heights_px / ratios /
computation / raw_zone_layout) byte-for-byte; only adds the new
col-axis keys (widths_px from css_cols fr, width_ratios, dynamic_cols=False).
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
C = len(rows_grid[0])
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
zl = compute_zone_layout(zones_data, gap=gap)
rows_str = " ".join(f"{h}px" for h in zl["heights_px"])
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": rows_str,
"heights_px": zl["heights_px"],
"widths_px": widths_px,
"ratios": zl["ratios"],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": zl["computation"],
"dynamic_rows": True,
"dynamic_cols": False,
"raw_zone_layout": zl,
}
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.
Mirror of _build_rows_dynamic. Returns a pixel grid-template-columns
string. PR 2 promotes vertical-2 override to dynamic_rows=True; in
PR 1 dynamic_rows stays False (legacy).
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
zl = compute_zone_layout_cols(zones_data, gap=gap)
cols_str = " ".join(f"{w}px" for w in zl["widths_px"])
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": preset["css_rows"],
"heights_px": heights_px,
"widths_px": zl["widths_px"],
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": zl["width_ratios"],
"computation": zl["computation"],
"dynamic_rows": False,
"dynamic_cols": True,
"raw_zone_layout": zl,
}
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
@@ -809,15 +1122,28 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
override_zone_geometries: Optional[dict[str, dict]] = None) -> dict:
"""Composition v0 layout preset → CSS grid 문자열.
horizontal-2 (= old type-b, 2-zone vertical stack) 만 dynamic heights 유지
(MDX 03 회귀 보존 — content_weight 기반). 다른 preset 은 fr default.
IMP-09 PR 1 contract — every layout_css return path carries
matching-length heights_px (= grid rows R) and widths_px (= grid cols C),
plus ratios / width_ratios / dynamic_rows / dynamic_cols. The
horizontal-2 grid CSS strings (areas/cols/rows) remain byte-identical
to the legacy path.
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id → {x,y,w,h}
slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제. horizontal-2 / vertical-2
만 처리. 다른 preset 은 일단 무시 + warning. 비율 합 != 1 이면 normalize.
Dynamic dispatch:
- topology="rows" -> _build_rows_dynamic (horizontal-2: row heights)
- topology="cols" -> _build_cols_dynamic (vertical-2: col widths)
- other topologies (single / T / inverted-T / side-T / 2x2) fall
through to _build_fr_default in PR 1; PR 2 enables the 2-D
dispatcher.
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id ->
{x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제.
PR 1 lock: horizontal-2 / vertical-2 만 처리 (legacy inline preserve).
다른 preset 은 warn-and-fallthrough (PR 2 가 unified _override_to_grid_tracks
로 promote).
"""
preset = LAYOUT_PRESETS[layout_preset]
positions = preset["positions"]
topology = preset.get("topology")
# ── Step D-ext : user override 처리 ──
if override_zone_geometries:
@@ -836,13 +1162,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
"cols": preset["css_cols"],
"rows": rows,
"heights_px": heights_px,
"widths_px": [SLIDE_BODY_WIDTH],
"ratios": [round(r / total, 3) for r in ratios],
"width_ratios": [1.0],
"computation": "user_override_geometry",
"dynamic_rows": True,
"dynamic_cols": False,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
elif layout_preset == "vertical-2":
# cols override — zone 의 w 비율로 fr 분배.
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
# populated in pixels for _compute_per_zone_geometry length contract.
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
@@ -850,47 +1181,44 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
total = sum(ratios)
if total > 0:
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
normalized = [r / total for r in ratios]
widths_px = [
int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1))))
for rr in normalized
]
diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px)
if diff != 0 and widths_px:
widths_px[-1] += diff
return {
"areas": preset["css_areas"],
"cols": cols,
"rows": preset["css_rows"],
"heights_px": [],
"ratios": [round(r / total, 3) for r in ratios],
"heights_px": [SLIDE_BODY_HEIGHT],
"widths_px": widths_px,
"ratios": [1.0],
"width_ratios": [round(rr, 3) for rr in normalized],
"computation": "user_override_geometry",
"dynamic_rows": False,
"dynamic_cols": True,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
else:
# 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 만). default layout_css 사용.",
file=sys.stderr,
)
if layout_preset == "horizontal-2":
zl = compute_zone_layout(zones_data, gap=gap)
rows = " ".join(f"{h}px" for h in zl["heights_px"])
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": rows,
"heights_px": zl["heights_px"],
"ratios": zl["ratios"],
"computation": zl["computation"],
"dynamic_rows": True,
"raw_zone_layout": zl,
}
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": preset["css_rows"],
"heights_px": [],
"ratios": [],
"computation": "fr_default_from_preset",
"dynamic_rows": False,
"raw_zone_layout": None,
}
# ── 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)
# PR 2 will dispatch T / inverted-T / side-T-{left,right} / 2x2 here.
# PR 3 will dispatch single here.
return _build_fr_default(preset)
# ─── Abort ──────────────────────────────────────────────────────
@@ -1287,6 +1615,21 @@ def _attempt_zone_ratio_retry(
)
return base_trace
# IMP-09 PR 1 retry gate — row-axis retry is only valid for layouts whose
# row geometry is dynamic. 2-D / dynamic_cols layouts and fr_default sinks
# would either misapply row-only redistribution or produce a no-op trace.
if layout_css.get("dynamic_cols", False):
base_trace["retry_skipped_reason"] = (
"layout has dynamic_cols (2-D topology) — "
"row-axis retry not applicable to 2-D layouts (IMP-09 lock)"
)
return base_trace
if not layout_css.get("dynamic_rows", False):
base_trace["retry_skipped_reason"] = (
"layout is fr_default_from_preset (no dynamic geometry) — retry no-op"
)
return base_trace
# 2. plan
base_trace["retry_attempted"] = True
base_trace["retry_action"] = "zone_ratio_retry"
@@ -2076,8 +2419,21 @@ def run_phase_z2_mvp1(
# 2. Load V4
v4 = load_v4_result()
# 3. Align sections to V4 granularity (### drill if needed)
sections = align_sections_to_v4_granularity(sections, v4)
# 3. Align sections to V4 granularity (### drill if needed).
# IMP-08 B-3 / Stage 5 R2 : forward override target ids so sub-id
# drag/drop targets force-drill their parent section even when V4
# carries the parent exact key (deterministic drag/drop addressing).
_override_target_sids: list[str] = []
if override_section_assignments:
for _sids in override_section_assignments.values():
for _sid in _sids:
if isinstance(_sid, str) and _sid:
_override_target_sids.append(_sid)
sections = align_sections_to_v4_granularity(
sections,
v4,
override_target_section_ids=_override_target_sids or None,
)
print(f" aligned : sections={len(sections)} ({[s.section_id for s in sections]})")
# ─── Step 5: V4 매칭 evidence (non-reject max-6 후보 list — 사용자 lock 2026-05-08) ───
@@ -2871,7 +3227,7 @@ def run_phase_z2_mvp1(
# reporting only. Runtime selection goes through _resolve_v4_section_key
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
# yields a "V4 entry 없음" report line (runtime impact zero).
# yields a "V4 entry missing" report line (runtime impact zero).
try:
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
_v4_full = yaml.safe_load(_vf)
@@ -3017,11 +3373,20 @@ def run_phase_z2_mvp1(
layout_css = build_layout_css(
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
)
# IMP-09 PR 1 — unified per-zone geometry aggregation across all
# layouts. Spanning zones in 2-D layouts (T / 2x2 from PR 2 onward)
# are handled by _compute_per_zone_geometry; in PR 1 the helper
# operates on row/col-static or row-dynamic / col-dynamic outputs.
per_zone_geo = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
for dz, geo in zip(debug_zones, per_zone_geo):
dz["height_px"] = geo["zone_height_px"]
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"]:
for dz, h, r in zip(debug_zones, layout_css["heights_px"], layout_css["ratios"]):
dz["height_px"] = h
dz["ratio"] = r
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']}")
else:
print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})")
@@ -3182,6 +3547,8 @@ def run_phase_z2_mvp1(
"position": dz["position"],
"zone_height_px_planned": dz.get("height_px"),
"zone_ratio_planned": dz.get("ratio"),
"zone_width_px_planned": dz.get("width_px"),
"zone_col_ratio_planned": dz.get("width_ratio"),
"min_height_px": visual_hints.get("min_height_px"),
"frame_cardinality_strict": cardinality.get("strict"),
"sub_zones_planned": [
@@ -3206,7 +3573,9 @@ def run_phase_z2_mvp1(
run_dir, 8, "zone_region_ratios",
data={
"zone_heights_px_planned": layout_css.get("heights_px"),
"zone_widths_px_planned": layout_css.get("widths_px"),
"zone_ratios_planned": layout_css.get("ratios"),
"zone_col_ratios_planned": layout_css.get("width_ratios"),
"per_zone_plan": zone_region_plans,
# Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
"step8_conn_placeholder_signals": _step8_placeholder_signals,
+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 */
File diff suppressed because it is too large Load Diff
View File
@@ -0,0 +1,31 @@
input:
layout_preset: horizontal-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.5
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" "bottom"'
cols: 1fr
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 1180
ratios:
- 0.489
- 0.487
width_ratios:
- 1.0
computation: min_height_first + content_weight_distribution
dynamic_rows: true
dynamic_cols: false
@@ -0,0 +1,41 @@
input:
layout_preset: horizontal-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.5
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries:
top:
x: 0
y: 0
w: 1.0
h: 0.3
bottom:
x: 0
y: 0.3
w: 1.0
h: 0.7
expected_layout_css:
areas: '"top" "bottom"'
cols: 1fr
rows: 176px 410px
heights_px:
- 176
- 410
widths_px:
- 1180
ratios:
- 0.3
- 0.7
width_ratios:
- 1.0
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: false
@@ -0,0 +1,31 @@
input:
layout_preset: horizontal-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.8
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.2
min_height_px: 150
override_zone_geometries: null
expected_layout_css:
areas: '"top" "bottom"'
cols: 1fr
rows: 377px 194px
heights_px:
- 377
- 194
widths_px:
- 1180
ratios:
- 0.644
- 0.332
width_ratios:
- 1.0
computation: min_height_first + content_weight_distribution
dynamic_rows: true
dynamic_cols: false
@@ -0,0 +1,31 @@
input:
layout_preset: vertical-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.5
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 right"'
cols: 583px 583px
rows: 1fr
heights_px:
- 585
widths_px:
- 583
- 583
ratios:
- 1.0
width_ratios:
- 0.494
- 0.494
computation: content_weight_distribution_cols
dynamic_rows: false
dynamic_cols: true
@@ -0,0 +1,41 @@
input:
layout_preset: vertical-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.5
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries:
left:
x: 0
y: 0
w: 0.4
h: 1.0
right:
x: 0.4
y: 0
w: 0.6
h: 1.0
expected_layout_css:
areas: '"left right"'
cols: 40.0fr 60.0fr
rows: 1fr
heights_px:
- 585
widths_px:
- 466
- 700
ratios:
- 1.0
width_ratios:
- 0.4
- 0.6
computation: user_override_geometry
dynamic_rows: false
dynamic_cols: true
@@ -0,0 +1,31 @@
input:
layout_preset: vertical-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.7
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.3
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"left right"'
cols: 816px 350px
rows: 1fr
heights_px:
- 585
widths_px:
- 816
- 350
ratios:
- 1.0
width_ratios:
- 0.692
- 0.297
computation: content_weight_distribution_cols
dynamic_rows: false
dynamic_cols: true
@@ -0,0 +1,24 @@
case_id: horizontal2_dynamic_rows
description: |
horizontal-2 layout with dynamic_rows=True must pass the IMP-09 retry
gate. The base trace should record retry_attempted=True (legacy
plan/rerender path continues). retry_skipped_reason MUST NOT contain
either of the IMP-09 gate skip strings.
input_layout_css:
areas: '"top" "bottom"'
cols: 1fr
rows: 333px 238px
heights_px: [333, 238]
widths_px: [1180]
ratios: [0.569, 0.407]
width_ratios: [1.0]
dynamic_rows: true
dynamic_cols: false
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: true
retry_skipped_reason_excludes:
- "dynamic_cols"
- "fr_default_from_preset"
@@ -0,0 +1,23 @@
case_id: single_fr_default
description: |
Any layout that fell through to fr_default_from_preset (single,
T-shape, 2x2 in PR 1) has neither dynamic_rows nor dynamic_cols.
Row-axis retry is a no-op and must be skipped by the IMP-09 gate
with a fr_default_from_preset skip reason.
input_layout_css:
areas: '"top top" "bottom-left bottom-right"'
cols: 1fr 1fr
rows: 1fr 1fr
heights_px: [285, 286]
widths_px: [583, 583]
ratios: [0.487, 0.489]
width_ratios: [0.494, 0.494]
dynamic_rows: false
dynamic_cols: false
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "fr_default_from_preset"
@@ -0,0 +1,24 @@
case_id: vertical2_dynamic_cols
description: |
vertical-2 layout with dynamic_cols=True must be skipped by the
IMP-09 retry gate before plan/rerender, because the existing
apply_retry_to_layout_css mutates only row-axis fields and would
produce a misleading trace if it ran on a column-dynamic layout.
input_layout_css:
areas: '"left right"'
cols: 583px 583px
rows: 1fr
heights_px: [585]
widths_px: [583, 583]
ratios: [1.0]
width_ratios: [0.494, 0.494]
dynamic_rows: false
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"
- "IMP-09"
+158
View File
@@ -0,0 +1,158 @@
"""IMP-09 PR 1 — build_layout_css contract tests.
Verifies horizontal-2 byte-identity for the legacy grid strings
(areas / cols / rows) and that every return path now carries the new
length-locked col-axis keys (widths_px / width_ratios / dynamic_cols).
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import (
GRID_GAP,
SLIDE_BODY_HEIGHT,
SLIDE_BODY_WIDTH,
build_layout_css,
)
def _zone(position: str, score: float, min_h: int = 100) -> dict:
return {
"position": position,
"template_id": f"MOCK_{position}",
"content_weight": {"score": score},
"min_height_px": min_h,
}
# ────────────────────── new-key contract ──────────────────────
NEW_KEYS = {"widths_px", "width_ratios", "dynamic_cols"}
def test_all_presets_carry_new_col_axis_keys():
"""Every PR 1 return path must include widths_px / width_ratios /
dynamic_cols, and heights_px / widths_px must be length-locked to
the catalog grid (R rows, C cols)."""
cases = [
("single", [_zone("primary", 1.0)]),
("horizontal-2", [_zone("top", 0.6), _zone("bottom", 0.4)]),
("vertical-2", [_zone("left", 0.5), _zone("right", 0.5)]),
("top-1-bottom-2", [
_zone("top", 0.5),
_zone("bottom-left", 0.25),
_zone("bottom-right", 0.25),
]),
("grid-2x2", [
_zone("top-left", 0.25),
_zone("top-right", 0.25),
_zone("bottom-left", 0.25),
_zone("bottom-right", 0.25),
]),
]
for preset, zones in cases:
result = build_layout_css(preset, zones)
missing = NEW_KEYS - set(result)
assert not missing, f"{preset} missing new keys: {missing}"
# heights_px / widths_px never empty in PR 1 (length-locked).
assert len(result["heights_px"]) > 0, f"{preset} empty heights_px"
assert len(result["widths_px"]) > 0, f"{preset} empty widths_px"
# ────────────────────── horizontal-2 byte-identity ──────────────────────
def test_horizontal_2_grid_strings_match_legacy():
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
result = build_layout_css("horizontal-2", zones)
# Legacy contract: areas / cols / rows strings preserved.
assert result["areas"] == '"top" "bottom"'
assert result["cols"] == "1fr"
assert result["rows"].count("px") == 2
# heights_px sum to body height; ratios consistent.
assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP
assert result["dynamic_rows"] is True
assert result["dynamic_cols"] is False
# New col-axis defaults: full body width, ratio 1.0.
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
assert result["width_ratios"] == [1.0]
def test_horizontal_2_override_preserves_rows():
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
override = {
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
}
result = build_layout_css(
"horizontal-2", zones, override_zone_geometries=override
)
assert result["computation"] == "user_override_geometry"
assert result["dynamic_rows"] is True
assert result["dynamic_cols"] is False
assert result["heights_px"][0] < result["heights_px"][1]
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
# Override ratio target.
assert result["ratios"] == [0.3, 0.7]
# ────────────────────── vertical-2 new dynamic ──────────────────────
def test_vertical_2_normal_produces_dynamic_cols():
zones = [_zone("left", 0.7), _zone("right", 0.3)]
result = build_layout_css("vertical-2", zones)
assert result["dynamic_rows"] is False
assert result["dynamic_cols"] is True
# cols string is px-based (no fr).
assert "fr" not in result["cols"]
assert result["cols"].count("px") == 2
# Heights span full body in a single row.
assert result["heights_px"] == [SLIDE_BODY_HEIGHT]
# Widths reflect 70/30 weight split.
assert result["widths_px"][0] > result["widths_px"][1]
def test_vertical_2_override_keeps_fr_cols_legacy():
"""PR 1 v-2 override path keeps legacy fr-string cols but now
populates widths_px in pixels for downstream consumers."""
zones = [_zone("left", 0.5), _zone("right", 0.5)]
override = {
"left": {"x": 0, "y": 0, "w": 0.4, "h": 1.0},
"right": {"x": 0.4, "y": 0, "w": 0.6, "h": 1.0},
}
result = build_layout_css(
"vertical-2", zones, override_zone_geometries=override
)
assert result["computation"] == "user_override_geometry"
assert "fr" in result["cols"]
assert result["dynamic_cols"] is True
assert result["dynamic_rows"] is False
# widths_px now populated.
assert len(result["widths_px"]) == 2
assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP
assert result["width_ratios"] == [0.4, 0.6]
# ────────────────────── fr_default sink (PR 1) ──────────────────────
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"] == "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
@@ -0,0 +1,101 @@
"""IMP-09 PR 1 — _compute_per_zone_geometry tests (1-D paths).
Verifies the unified per-zone geometry aggregator on horizontal-2 and
vertical-2 (the two 1-D presets active in PR 1). 2-D spanning zone
cases (T / 2x2) are exercised in PR 2.
The helper aggregates grid-track sizes into per-zone dimensions and
must produce length-locked outputs:
- layout_css["heights_px"] length == R (parsed css_areas rows)
- layout_css["widths_px"] length == C (parsed css_areas cols)
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import (
GRID_GAP,
SLIDE_BODY_HEIGHT,
SLIDE_BODY_WIDTH,
_compute_per_zone_geometry,
build_layout_css,
)
def _zone(position: str, score: float) -> dict:
return {
"position": position,
"template_id": f"MOCK_{position}",
"content_weight": {"score": score},
"min_height_px": 100,
}
def test_horizontal_2_per_zone_widths_match_slide_body():
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
layout_css = build_layout_css("horizontal-2", zones)
debug_zones = [{"position": "top"}, {"position": "bottom"}]
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
# Heights mirror layout_css.heights_px.
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
def test_vertical_2_per_zone_heights_match_slide_body():
zones = [_zone("left", 0.5), _zone("right", 0.5)]
layout_css = build_layout_css("vertical-2", zones)
debug_zones = [{"position": "left"}, {"position": "right"}]
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
# Both zones share the single row => height == SLIDE_BODY_HEIGHT.
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
assert per_zone[1]["zone_height_px"] == SLIDE_BODY_HEIGHT
# Widths mirror layout_css.widths_px.
assert per_zone[0]["zone_width_px"] == layout_css["widths_px"][0]
assert per_zone[1]["zone_width_px"] == layout_css["widths_px"][1]
def test_heights_px_length_mismatch_raises():
layout_css = {
"areas": '"top" "bottom"',
"heights_px": [300], # wrong length, expected 2
"widths_px": [SLIDE_BODY_WIDTH],
}
with pytest.raises(ValueError, match="heights_px length"):
_compute_per_zone_geometry(
layout_css, [{"position": "top"}], GRID_GAP
)
def test_widths_px_length_mismatch_raises():
layout_css = {
"areas": '"left right"',
"heights_px": [SLIDE_BODY_HEIGHT],
"widths_px": [600], # wrong length, expected 2
}
with pytest.raises(ValueError, match="widths_px length"):
_compute_per_zone_geometry(
layout_css, [{"position": "left"}], GRID_GAP
)
def test_unknown_position_raises():
zones = [_zone("top", 0.5), _zone("bottom", 0.5)]
layout_css = build_layout_css("horizontal-2", zones)
debug_zones = [{"position": "ghost"}]
with pytest.raises(ValueError, match="not present in css_areas"):
_compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
def test_fr_default_single_returns_full_body():
# 'single' is the fr_default sink in PR 1; widths_px / heights_px
# must still be populated (length 1 each).
layout_css = build_layout_css("single", [_zone("primary", 1.0)])
debug_zones = [{"position": "primary"}]
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
@@ -0,0 +1,76 @@
"""IMP-09 PR 1 — compute_zone_layout_cols tests.
Column-axis weight-only solver. Mirrors compute_zone_layout for rows.
No min_width_px contract exists in frame_contracts.yaml (verified
during Stage 2), so column distribution is purely content_weight.
"""
from __future__ import annotations
from src.phase_z2_pipeline import (
GRID_GAP,
SLIDE_BODY_WIDTH,
compute_zone_layout_cols,
)
def _zone(position: str, score: float) -> dict:
return {
"position": position,
"template_id": f"MOCK_{position}",
"content_weight": {"score": score},
}
def test_empty_zones_returns_empty_result():
result = compute_zone_layout_cols([])
assert result["widths_px"] == []
assert result["width_ratios"] == []
def test_two_equal_zones_split_evenly():
zones = [_zone("left", 0.5), _zone("right", 0.5)]
result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - GRID_GAP # one gap between two zones
assert sum(result["widths_px"]) == available
assert result["widths_px"][0] == result["widths_px"][1]
assert result["computation"] == "content_weight_distribution_cols"
def test_asymmetric_weights_distribute_by_ratio():
zones = [_zone("left", 0.8), _zone("right", 0.2)]
result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - GRID_GAP
assert sum(result["widths_px"]) == available
# left should be ~4x right
assert result["widths_px"][0] > result["widths_px"][1] * 3
def test_zero_weight_guard_equal_split():
zones = [_zone("left", 0.0), _zone("right", 0.0)]
result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - GRID_GAP
assert sum(result["widths_px"]) == available
assert result["widths_px"][0] == result["widths_px"][1]
# weight_shares fallback to equal share.
assert result["weight_shares"] == [0.5, 0.5]
def test_integer_rounding_absorbed_by_last_zone():
# Three zones with weights that don't divide evenly.
zones = [
_zone("a", 0.333333),
_zone("b", 0.333333),
_zone("c", 0.333334),
]
result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - 2 * GRID_GAP
assert sum(result["widths_px"]) == available
def test_width_ratios_match_total_width():
zones = [_zone("left", 0.6), _zone("right", 0.4)]
result = compute_zone_layout_cols(zones)
# width_ratios should be widths_px / SLIDE_BODY_WIDTH (not / available)
assert abs(
result["width_ratios"][0] - result["widths_px"][0] / SLIDE_BODY_WIDTH
) < 1e-3
+100
View File
@@ -0,0 +1,100 @@
"""IMP-09 PR 1 — fixture-driven regression checks.
Loads the YAML snapshots under tests/phase_z2/fixtures/ and exercises
build_layout_css + _attempt_zone_ratio_retry against them. Any drift
in IMP-09 output forces a fixture refresh, which is the lock surface
called out in Stage 3 round 4 §5.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from src.phase_z2_pipeline import _attempt_zone_ratio_retry, build_layout_css
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def _load_yaml(path: Path) -> dict:
with path.open(encoding="utf-8") as f:
return yaml.safe_load(f)
# ──────────────────────── build_layout_css fixtures ────────────────────────
_BUILD_DIR = FIXTURES_DIR / "build_layout_css"
_BUILD_FIXTURES = sorted(_BUILD_DIR.glob("*.yaml")) if _BUILD_DIR.exists() else []
@pytest.mark.parametrize(
"fixture_path",
_BUILD_FIXTURES,
ids=[p.stem for p in _BUILD_FIXTURES],
)
def test_build_layout_css_matches_fixture(fixture_path: Path):
payload = _load_yaml(fixture_path)
inp = payload["input"]
expected = payload["expected_layout_css"]
result = build_layout_css(
inp["layout_preset"],
inp["zones_data"],
override_zone_geometries=inp.get("override_zone_geometries"),
)
# raw_zone_layout is intentionally not snapshotted (contains
# solver internals); compare the rest.
actual = {k: v for k, v in result.items() if k != "raw_zone_layout"}
assert actual == expected, (
f"layout_css drift in fixture {fixture_path.name}:\n"
f" expected={expected}\n actual={actual}"
)
# ────────────────────────── retry_gate fixtures ──────────────────────────
_RETRY_DIR = FIXTURES_DIR / "retry_gate"
_RETRY_FIXTURES = sorted(_RETRY_DIR.glob("*.yaml")) if _RETRY_DIR.exists() else []
@pytest.mark.parametrize(
"fixture_path",
_RETRY_FIXTURES,
ids=[p.stem for p in _RETRY_FIXTURES],
)
def test_retry_gate_matches_fixture(fixture_path: Path, tmp_path: Path):
payload = _load_yaml(fixture_path)
layout_css = payload["input_layout_css"]
router_decision = payload["router_decision"]
expected = payload["expected_gate"]
trace = _attempt_zone_ratio_retry(
run_dir=tmp_path,
out_path=tmp_path / "final.html",
slide_title="fixture",
slide_footer=None,
zones_data=[],
debug_zones=[],
layout_preset="fixture",
layout_css=layout_css,
overflow={},
fit_classification={},
router_decision=router_decision,
gap_px=14,
)
assert trace["retry_attempted"] == expected["retry_attempted"]
skip_reason = trace.get("retry_skipped_reason")
for needle in expected.get("retry_skipped_reason_contains", []):
assert skip_reason is not None and needle in skip_reason, (
f"expected {needle!r} in retry_skipped_reason, got {skip_reason!r}"
)
for forbidden in expected.get("retry_skipped_reason_excludes", []):
if skip_reason is not None:
assert forbidden not in skip_reason, (
f"forbidden {forbidden!r} found in retry_skipped_reason {skip_reason!r}"
)
@@ -0,0 +1,71 @@
"""IMP-09 PR 1 — _parse_css_areas strict validation tests.
Covers the four ValueError cases declared in the Stage 3 round 4 lock
(plan §2-D): empty input, no quoted rows, empty row tokens, and
non-rectangular grids. Also exercises positive parsing on all 8
catalog presets so any future catalog drift in row/col counts is
caught here.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import _parse_css_areas
def test_parse_empty_string_raises():
with pytest.raises(ValueError, match="no quoted row strings"):
_parse_css_areas("")
def test_parse_no_quotes_raises():
with pytest.raises(ValueError, match="no quoted row strings"):
_parse_css_areas("top top bottom-left bottom-right")
def test_parse_empty_row_raises():
# Whitespace-only quoted row -> tokens list is empty.
with pytest.raises(ValueError, match="empty row"):
_parse_css_areas('" "')
def test_parse_non_rectangular_raises():
# First row has 1 token, second row has 2 tokens.
with pytest.raises(ValueError, match="non-rectangular"):
_parse_css_areas('"top" "bottom-left bottom-right"')
def test_parse_single_zone():
rows, seen = _parse_css_areas('"primary"')
assert rows == [["primary"]]
assert seen == ["primary"]
def test_parse_horizontal_2():
rows, seen = _parse_css_areas('"top" "bottom"')
assert rows == [["top"], ["bottom"]]
assert seen == ["top", "bottom"]
def test_parse_vertical_2():
rows, seen = _parse_css_areas('"left right"')
assert rows == [["left", "right"]]
assert seen == ["left", "right"]
def test_parse_top_1_bottom_2_span():
rows, seen = _parse_css_areas('"top top" "bottom-left bottom-right"')
assert rows == [["top", "top"], ["bottom-left", "bottom-right"]]
# 'top' should appear once in seen even though it occupies two cells.
assert seen == ["top", "bottom-left", "bottom-right"]
def test_parse_grid_2x2_four_zones():
rows, seen = _parse_css_areas(
'"top-left top-right" "bottom-left bottom-right"'
)
assert rows == [
["top-left", "top-right"],
["bottom-left", "bottom-right"],
]
assert seen == ["top-left", "top-right", "bottom-left", "bottom-right"]
+49
View File
@@ -0,0 +1,49 @@
"""IMP-09 PR 1 — _parse_fr_string tests.
Catalog presets only use `1fr` / `1fr 1fr` specs (verified
templates/phase_z2/layouts/layouts.yaml). The helper must reject
non-fr tokens and round to integer pixel sizes summing to `total`.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import _parse_fr_string
def test_single_fr_returns_full_total():
assert _parse_fr_string("1fr", 585) == [585]
def test_two_equal_fr_splits_evenly():
result = _parse_fr_string("1fr 1fr", 1180)
assert result == [590, 590]
assert sum(result) == 1180
def test_unequal_fr_distributes_by_ratio():
result = _parse_fr_string("2fr 1fr", 300)
assert sum(result) == 300
assert result[0] > result[1]
def test_rounding_absorbed_by_last_track():
# 1fr 1fr 1fr / total=100 -> 33,33,33 + diff 1 absorbed by last.
result = _parse_fr_string("1fr 1fr 1fr", 100)
assert sum(result) == 100
assert result == [33, 33, 34]
def test_non_fr_token_raises():
with pytest.raises(ValueError, match="non-fr token"):
_parse_fr_string("200px 1fr", 1000)
def test_empty_spec_raises():
with pytest.raises(ValueError, match="empty spec"):
_parse_fr_string("", 1000)
def test_zero_fr_raises():
with pytest.raises(ValueError, match="total fr"):
_parse_fr_string("0fr 0fr", 1000)
+129
View File
@@ -0,0 +1,129 @@
"""IMP-09 PR 1 — retry gate tests (_attempt_zone_ratio_retry early exit).
Stage 3 round 4 lock §2-A: row-axis retry must skip when layout has
dynamic_cols=True (2-D topology) OR dynamic_rows=False (fr_default
sink). The horizontal-2 path (dynamic_rows=True, dynamic_cols=False)
must still proceed through the gate.
These tests exercise the gate by routing the request through
_attempt_zone_ratio_retry with router_active=True + proposed
zone_ratio_retry — but with layout_css fields that should trip the
gate. We confirm the early skip by asserting retry_attempted==False
and retry_skipped_reason content.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from src.phase_z2_pipeline import _attempt_zone_ratio_retry
_ROUTER_ACTIVE = {
"router_active": True,
"proposed_actions_summary": ["zone_ratio_retry"],
}
def _dummy_kwargs(layout_css: dict, tmp_path: Path) -> dict:
"""All params required by _attempt_zone_ratio_retry. Only
`layout_css` and `router_decision` matter pre-gate."""
return {
"run_dir": tmp_path,
"out_path": tmp_path / "final.html",
"slide_title": "test",
"slide_footer": None,
"zones_data": [],
"debug_zones": [],
"layout_preset": "horizontal-2",
"layout_css": layout_css,
"overflow": {},
"fit_classification": {},
"router_decision": _ROUTER_ACTIVE,
"gap_px": 14,
}
def test_vertical_2_dynamic_cols_skips_retry(tmp_path):
layout_css = {
"areas": '"left right"',
"cols": "583px 583px",
"rows": "1fr",
"heights_px": [585],
"widths_px": [583, 583],
"ratios": [1.0],
"width_ratios": [0.494, 0.494],
"dynamic_rows": False,
"dynamic_cols": True,
}
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
assert trace["retry_attempted"] is False
assert "dynamic_cols" in trace["retry_skipped_reason"]
assert "IMP-09" in trace["retry_skipped_reason"]
def test_fr_default_sink_skips_retry(tmp_path):
# PR 1 single / T-shape / 2x2 fall through to fr_default and must
# not enter row-only retry plan.
layout_css = {
"areas": '"top top" "bottom-left bottom-right"',
"cols": "1fr 1fr",
"rows": "1fr 1fr",
"heights_px": [285, 286],
"widths_px": [583, 583],
"ratios": [0.487, 0.489],
"width_ratios": [0.494, 0.494],
"dynamic_rows": False,
"dynamic_cols": False,
}
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
assert trace["retry_attempted"] is False
assert "fr_default_from_preset" in trace["retry_skipped_reason"]
def test_horizontal_2_dynamic_rows_passes_gate(tmp_path):
"""horizontal-2 with dynamic_rows=True must pass the gate. The
test does not need plan_zone_ratio_retry to succeed; it only
asserts the gate did not early-skip with one of the new
skip reasons."""
layout_css = {
"areas": '"top" "bottom"',
"cols": "1fr",
"rows": "333px 238px",
"heights_px": [333, 238],
"widths_px": [1180],
"ratios": [0.569, 0.407],
"width_ratios": [1.0],
"dynamic_rows": True,
"dynamic_cols": False,
}
# plan_zone_ratio_retry will return None because debug_zones is
# empty, so retry_attempted=True but plan==None.
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
assert trace["retry_attempted"] is True
# The gate was passed; skip reason (if any) is the legacy
# plan-failure reason, not the new gate reasons.
skip_reason = trace.get("retry_skipped_reason")
if skip_reason is not None:
assert "dynamic_cols" not in skip_reason
assert "fr_default_from_preset" not in skip_reason
def test_router_inactive_skips_before_gate(tmp_path):
"""When router_active=False, the early skip happens before the
new IMP-09 gate. Verify the existing behavior is unchanged."""
layout_css = {
"areas": '"left right"',
"dynamic_rows": False,
"dynamic_cols": True,
"heights_px": [585],
"widths_px": [583, 583],
"ratios": [1.0],
"width_ratios": [0.5, 0.5],
}
kwargs = _dummy_kwargs(layout_css, tmp_path)
kwargs["router_decision"] = {"router_active": False}
trace = _attempt_zone_ratio_retry(**kwargs)
assert trace["retry_attempted"] is False
assert "router_active=False" in trace["retry_skipped_reason"]
+60 -1
View File
@@ -111,7 +111,9 @@ def test_mdx_section_default_construction_preserves_4_positional_callers():
def test_align_passthrough_when_v4_key_exact_match():
# Section already aligned to V4 key — aligner keeps it untouched.
# Section already aligned to V4 key (no override target): aligner
# keeps it untouched. Parent-level V4 evidence flows via exact-match
# lookup.
sections = [_section("04-1", 1, "1. Top", "body")]
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
out = align_sections_to_v4_granularity(sections, v4)
@@ -119,6 +121,63 @@ def test_align_passthrough_when_v4_key_exact_match():
assert out[0].section_id == "04-1"
def test_align_parent_v4_exact_keeps_section_when_no_override_targets_sub():
# Backward-compat axis: when V4 carries the parent exact key and no
# drag/drop override targets a sub-id of this section, the aligner
# MUST keep the parent (preserves V4 evidence at parent granularity).
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
sections = [_section("03-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {"03-2": {"judgments_full32": []}}}
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["03-2"]
def test_align_force_drills_when_override_targets_sub_id_with_parent_in_v4():
# Stage 5 R2 blocker-fix regression: when V4 has the parent exact key
# AND an override targets a sub-id of that section, the aligner MUST
# drill regardless of V4 parent presence. This makes drag/drop
# addressing deterministic across all V4 yaml shapes.
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
sections = [_section("04-2", 2, "2. Parent", raw)]
v4 = {
"mdx_sections": {
"04-2": {"judgments_full32": []}, # parent V4 entry present
"04-2.1": {"judgments_full32": []}, # plus decimal sub entries
"04-2.2": {"judgments_full32": []},
}
}
out = align_sections_to_v4_granularity(
sections, v4, override_target_section_ids=["04-2-sub-1"]
)
# Force-drill: parent id MUST be replaced by canonical sub-ids.
assert [s.section_id for s in out] == ["04-2-sub-1", "04-2-sub-2"]
# Decimal aliases preserved (N-R5: decimal heading_number).
assert out[0].v4_alias_keys == ["04-2.1"]
assert out[1].v4_alias_keys == ["04-2.2"]
def test_align_top_level_override_target_does_not_force_drill_other_sections():
# Top-level override target ("primary=03-1") has no derive_parent_id,
# so it MUST NOT force-drill any section. Only "X-sub-N" targets
# trigger force-drill on parent X.
raw = "### 2.1 First\nbody1\n"
sections = [
_section("03-1", 1, "1. Top", "body"),
_section("03-2", 2, "2. Parent", raw),
]
v4 = {
"mdx_sections": {
"03-1": {"judgments_full32": []},
"03-2": {"judgments_full32": []},
}
}
out = align_sections_to_v4_granularity(
sections, v4, override_target_section_ids=["03-1"]
)
# No sub-id target -> both sections kept at parent granularity.
assert [s.section_id for s in out] == ["03-1", "03-2"]
def test_align_drill_emits_canonical_ordinal_id_with_decimal_alias():
# Decimal H3 headings -> canonical ordinal id + decimal alias (legacy V4 key).
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"