Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e6259905e | ||
|
|
c537bf26ac | ||
|
|
201099e53b | ||
|
|
8f6cffc2a7 |
@@ -7,6 +7,8 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
.orchestrator/
|
||||
tmpim*.json/
|
||||
node_modules/
|
||||
data/
|
||||
|
||||
|
||||
+25
-198
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) 가 ' 로 바꿔
|
||||
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 */
|
||||
|
||||
+1619
-155
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user