feat(#65): IMP-36 fit/rotation generalization (u1~u8)
Generalize Phase Z frame partial responsive fit / rotation to four canonical
F13/F14/F20/F8 family partials. Surface = 13 canonical partials; 19
builder-only contracts remain explicitly out of scope.
u1 test_imp17_comment_anchor: re-pin L570->L578 (restructure+IMP-17),
L571->L579 (IMP-29 -> IMP-47B supersession). Stage 1 red baseline gate.
u2 frame_contracts.yaml: add rotation_eligible (P1) + body_fit_pattern2 (P2)
bool axes on 13 partial-backed contracts. P1 True: F13/F14/F20/F8 (4).
P2 True: F23 + P1_set (5). F29 columns[1].body_parser column_plain ->
column_with_transform (P3 parity).
u3 test_imp36_fit_rotation_generalization (NEW, 166 lines): static
parametrized assertions for P1 metadata + CQ presence, P1 opt-out
absence, P2 --max-body-lines + clamp + cqh, P2 opt-out absence, 19
builder-only exclusion.
u4 three_parallel_requirements (F13): introduce f13b-root container-name +
container-type:size + @container (aspect-ratio<1.5) rotation;
add inline --max-body-lines + body line-height clamp/cqh/calc.
u5 three_persona_benefits (F14): f14b-root P1 + P2 cqh/jinja body fit.
Persona colors (#285b4a/#445a2f/#743002) and circle SVG aspect 1/1
preserved.
u6 dx_sw_necessity_three_perspectives (F20): f20b-root P1 + P2 cqh/jinja
body fit under IMP-49 partial-fidelity lock.
u7 info_management_what_how_when (F8): f8b-root P1 + P2 cqh/jinja body fit.
u8 test_imp36_overflow_chain_self_fire (NEW, 299 lines): Selenium self-fire
harness for F13/F14/F20/F8 at aspect 1.78 vs 1.0. Asserts line-height
changes, font-size invariance across all 4 frames (no per-frame exempt),
grid columns rotate 3 -> 1, OVERFLOW_CASCADE_ORDER remains 4-tuple.
Stage 4 verification (HEAD 6f1c736 pre-commit baseline):
u1 2/2 PASS, u3 33/33 PASS, u8 9/9 PASS (live Chrome).
Regression sweep tests/phase_z2 + tests/orchestrator_unit 335/335 PASS.
font-size mutations introduced: 0.
Pre-existing red (test_imp47b_step12_ai_wiring x3, ai_fallback_master_flag
default_off x1) verified unchanged via stash swap -> not introduced.
Guardrails honored:
- cqh / clamp / container query only (no shared margin/padding/gap shrink).
- font-size invariant under aspect change (P2 mutates line-height +
--max-body-lines only).
- No cross-frame .fNb__ class borrowing (IMP-49 partial-fidelity lock).
- F14 circle SVG aspect 1/1 untouched; persona colors preserved.
- AI isolation: no HTML structure generation; AI calls remain zone-content.
- 1 turn = 1 step; commit excludes .claude/settings.json and all
out-of-scope untracked worktree per Stage 4 binding contract.
source_comment_ids: Stage 1 #13/#14; Stage 2 #21/#22; Stage 3 #4 + Codex #4
YES; Stage 4 Claude #1 + Codex #3 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,10 @@ three_parallel_requirements:
|
||||
frame_id: 1171281190
|
||||
family: three_parallel
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -79,6 +83,10 @@ process_product_two_way:
|
||||
frame_id: 1171281210
|
||||
family: two_column_h3
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: h3_subsections
|
||||
cardinality:
|
||||
strict: 2 # F29 frame = 2 visual columns. ≠2 → fallback.
|
||||
@@ -122,7 +130,7 @@ process_product_two_way:
|
||||
body_parser: column_with_transform # 첫 top-bullet AS-IS/TO-BE 표 인식
|
||||
- title_to: banner_right
|
||||
body_to: product
|
||||
body_parser: column_plain # 모든 section = 일반 text_lines
|
||||
body_parser: column_with_transform # IMP-36 (Gitea #65 u2) P3 parity — process column 과 동일 transform 인식 (좌/우 대칭)
|
||||
|
||||
|
||||
bim_issues_quadrant_four:
|
||||
@@ -130,6 +138,10 @@ bim_issues_quadrant_four:
|
||||
frame_id: 1171281193
|
||||
family: bim_issues_quadrant
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 4-quadrant 고정 grid — rotation 부적합
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
# F16 정책 = pad_to=4 + truncate>4 (legacy 와 동일). cardinality strict 화는 본 transition 범위 외.
|
||||
# 향후 normal path 안정 후 strict 적용 + 위반 시 fallback path (FitError) 검토.
|
||||
@@ -193,6 +205,10 @@ three_persona_benefits:
|
||||
frame_id: 1171281191
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 persona = strict.
|
||||
@@ -258,6 +274,10 @@ construction_goals_three_circle_intersection:
|
||||
frame_id: 1171281189
|
||||
family: diagram
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 3-circle SVG diagram — rotation 부적합 (좌표 의존)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 메인 원 — strict.
|
||||
@@ -328,6 +348,10 @@ construction_bim_three_usage:
|
||||
frame_id: 1171281182
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 3 stacked rows — rotation 부적합 (수평 row 구조)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -397,6 +421,10 @@ bim_dx_comparison_table:
|
||||
frame_id: 1171281195
|
||||
family: table
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out (issue body 명시)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
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}`.
|
||||
@@ -462,6 +490,10 @@ dx_sw_necessity_three_perspectives:
|
||||
frame_id: 1171281198
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 perspective columns
|
||||
@@ -528,6 +560,10 @@ info_management_what_how_when:
|
||||
frame_id: 1171281179
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 sections (What / How / When)
|
||||
@@ -587,6 +623,10 @@ sw_reality_three_emphasis:
|
||||
frame_id: 1171281209
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 미적용 (Stage 2 selection — future eligibility TBD)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -644,6 +684,10 @@ bim_current_problems_paired:
|
||||
frame_id: 1171281194
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 4x2 paired rows — rotation 부적합 (2-axis row×side 구조)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets # mapper split_source allow-list 정합 (Codex round 60)
|
||||
layout_variant: paired_rows_4x2_alternating_pills # runtime projection model
|
||||
cardinality:
|
||||
@@ -735,6 +779,10 @@ app_sw_package_vs_solution:
|
||||
frame_id: 1171281203
|
||||
family: table
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit (Stage 1 canonical source)
|
||||
|
||||
source_shape: h3_subsections # F29 와 동일 — 2 h3 subsection = 2 column.
|
||||
cardinality:
|
||||
strict: 2 # 2 column (Package / Solution) — NOT row count.
|
||||
@@ -783,6 +831,10 @@ pre_construction_model_info_stacked:
|
||||
frame_id: 1171281180
|
||||
family: list
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 5-color cycle pill list — rotation 부적합
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
min: 4 # analysis.md min 4 / Figma 5-color cycle design floor.
|
||||
|
||||
@@ -65,6 +65,12 @@ slots :
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u6) P1 root — enable container queries on .f20b.
|
||||
container-type: size unlocks cqh/cqi/cqw + aspect-ratio matching against
|
||||
this element. container-name: f20b-root namespaces the @container rule
|
||||
below (partial-fidelity lock per IMP-49 #78 — no cross-frame borrowing). */
|
||||
container-type: size;
|
||||
container-name: f20b-root;
|
||||
}
|
||||
.f20b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -131,6 +137,25 @@ slots :
|
||||
color: #1d4d3e; /* PROMOTED — verbatim from upstream :208 (.card-title-1 -webkit-text-stroke) */
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u6) P2 body fit — line-height clamp scales with the
|
||||
per-column bullet count via the inline `--max-body-lines` Jinja style on
|
||||
each `.f20b__body`. 60cqh ≈ 3-col card body share of `.f20b` root height
|
||||
(zone-title ~15cqh + card header ~10cqh + remaining ~75cqh, split 60cqh
|
||||
for text lines + reserve for padding/gap). font-size unchanged
|
||||
(guardrail #6 — Stage 2 spec). Fallback 3 = file-header default
|
||||
"body 3-5 bullets per column". */
|
||||
.f20b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 3)), 1.6em);
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u6) P1 rotation rule — when the surrounding zone is
|
||||
narrow (aspect-ratio < 1.5), collapse the 3-column grid to a single
|
||||
column. The threshold matches the IMP-36 Stage 2 canonical (vertical-2 /
|
||||
세로형). Card header / body / bullet styles remain unchanged. */
|
||||
@container f20b-root (aspect-ratio < 1.5) {
|
||||
.f20b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- IMP-49 #78 u2 — namespace scope note :
|
||||
@@ -148,7 +173,8 @@ slots :
|
||||
{# 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>
|
||||
<div class="f20b__body">
|
||||
{# IMP-36 (Gitea #65 u6) P2 — inline `--max-body-lines` per column; code composes (Phase Z guardrail #7), defensive fallback to 3 matches the CSS default. #}
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_1_body | length) if slot_payload.perspective_1_body else 3 }};">
|
||||
{% 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 %}
|
||||
@@ -156,7 +182,7 @@ slots :
|
||||
</div>
|
||||
<div class="f20b__col">
|
||||
<div class="f20b__header">{{ slot_payload.perspective_2_label | safe }}</div>
|
||||
<div class="f20b__body">
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_2_body | length) if slot_payload.perspective_2_body else 3 }};">
|
||||
{% 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 %}
|
||||
@@ -164,7 +190,7 @@ slots :
|
||||
</div>
|
||||
<div class="f20b__col">
|
||||
<div class="f20b__header">{{ slot_payload.perspective_3_label | safe }}</div>
|
||||
<div class="f20b__body">
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_3_body | length) if slot_payload.perspective_3_body else 3 }};">
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
|
||||
@@ -48,6 +48,12 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u7) P1 — container-type: size unlocks cqh/cqi/cqw +
|
||||
aspect-ratio measurement on .f8b. container-name: f8b-root namespaces
|
||||
the rotation rule below (IMP-49 partial-fidelity lock — no cross-frame
|
||||
.fNb__ class borrowing). */
|
||||
container-type: size;
|
||||
container-name: f8b-root;
|
||||
}
|
||||
.f8b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -110,6 +116,16 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u7) P2 body fit — line-height cqh/clamp against the
|
||||
bullet count rendered per column. font-size 미변경 (사용자 룰 + Stage 2
|
||||
guardrail #6). 60cqh = approx body region share of .f8b after title +
|
||||
section header (title ≈ 15cqh + per-col header ≈ 12cqh → body ≈ 60cqh).
|
||||
fallback = 4 (file header L42 watch threshold "body 5+ bullets per
|
||||
column" → typical < 5). Additive cascade override; does not mutate the
|
||||
pre-existing .f8b__body .text-line block above. */
|
||||
.f8b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 4)), 1.6em);
|
||||
}
|
||||
.f8b__body .text-line--bullet::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
@@ -119,6 +135,14 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
.f8b__col:nth-child(1) .f8b__body .text-line--bullet::before { color: #2563eb; }
|
||||
.f8b__col:nth-child(2) .f8b__body .text-line--bullet::before { color: #ea580c; }
|
||||
.f8b__col:nth-child(3) .f8b__body .text-line--bullet::before { color: #16a34a; }
|
||||
|
||||
/* IMP-36 (Gitea #65 u7) P1 rotation rule — when zone aspect-ratio narrows
|
||||
below 1.5 (vertical-2 narrow / 임의 세로형 geometry), flip the 3-column
|
||||
grid to single column. Card header / body / bullet styles unchanged —
|
||||
only grid-template-columns flips from 1fr 1fr 1fr to 1fr. */
|
||||
@container f8b-root (aspect-ratio < 1.5) {
|
||||
.f8b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="f8b" data-frame-id="1171281179" data-template-id="info_management_what_how_when">
|
||||
@@ -126,7 +150,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
<div class="f8b__cols">
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_1_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_1_body | length) if slot_payload.section_1_body else 4 }};">
|
||||
{% if slot_payload.section_1_body %}
|
||||
{% for line in slot_payload.section_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 %}
|
||||
@@ -134,7 +158,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
</div>
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_2_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_2_body | length) if slot_payload.section_2_body else 4 }};">
|
||||
{% if slot_payload.section_2_body %}
|
||||
{% for line in slot_payload.section_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 %}
|
||||
@@ -142,7 +166,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
</div>
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_3_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_3_body | length) if slot_payload.section_3_body else 4 }};">
|
||||
{% if slot_payload.section_3_body %}
|
||||
{% for line in slot_payload.section_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -34,6 +34,12 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
gap: 4px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u4) P1 — partial-side container query root.
|
||||
container-type: size 로 aspect-ratio 측정 가능 (cqh / cqi / cqw 도
|
||||
동일 root 기준). container-name: f13b-root 는 frame_contracts.yaml
|
||||
rotation_eligible: true 와 짝. */
|
||||
container-type: size;
|
||||
container-name: f13b-root;
|
||||
}
|
||||
.f13b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -126,6 +132,22 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
}
|
||||
/* desc 안 .text-line 색 override */
|
||||
.f13b__desc .text-line { color: #3E3523; }
|
||||
|
||||
/* IMP-36 (Gitea #65 u4) P2 — body fit via cqh + clamp + --max-body-lines.
|
||||
section 의 text_lines 개수가 늘면 line-height 가 비례로 축소. font-size
|
||||
미변경 (사용자 룰). --max-body-lines fallback = 4 (section 평균 줄 수).
|
||||
20cqh = 한 section 이 차지하는 .f13b 컨테이너 비율 근사치 (3 section /
|
||||
col, body 영역 ≈ 80cqh → 25cqh/section 중 line 영역 ≈ 20cqh). */
|
||||
.f13b__desc {
|
||||
line-height: clamp(1.2em, calc(20cqh / var(--max-body-lines, 4)), 1.6em);
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u4) P1 — aspect-ratio < 1.5 rotation rule. zone 의
|
||||
가로:세로 비가 1.5 미만으로 좁아지면 (vertical-2 narrow / 또는 임의
|
||||
세로형 geometry) 3-col grid 가 1-col stack 으로 회전. */
|
||||
@container f13b-root (aspect-ratio < 1.5) {
|
||||
.f13b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="f13b" data-frame-id="1171281190" data-template-id="three_parallel_requirements">
|
||||
@@ -144,7 +166,7 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
<div class="f13b__section">
|
||||
<div class="f13b__heading">{{ section.heading | safe }}</div>
|
||||
{% if section.text_lines %}
|
||||
<div class="f13b__desc">
|
||||
<div class="f13b__desc" style="--max-body-lines: {{ section.text_lines | length }};">
|
||||
{% for line in section.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -70,6 +70,13 @@ Asset path runtime resolution :
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u5) P1 — partial-side container query root.
|
||||
container-type: size 로 aspect-ratio 측정 가능 (cqh / cqi / cqw 도
|
||||
동일 root 기준). container-name: f14b-root 는 frame_contracts.yaml
|
||||
rotation_eligible: true 와 짝. Circle badge (.f14b__badge aspect-ratio
|
||||
1/1) 는 별도 element — 본 root 의 aspect-ratio 측정 대상 아님. */
|
||||
container-type: size;
|
||||
container-name: f14b-root;
|
||||
}
|
||||
.f14b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -176,6 +183,22 @@ Asset path runtime resolution :
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u5) P2 — body fit via cqh + clamp + --max-body-lines.
|
||||
persona.body 의 bullet 개수가 늘면 line-height 가 비례로 축소. font-size
|
||||
미변경 (사용자 룰). --max-body-lines fallback = 7 (Figma 원본 frame 평균
|
||||
bullet 수, file header L8 참조). 60cqh = .f14b__body 영역 비율 근사치
|
||||
(title ≈ 15cqh + badge ≈ 18cqh + photo ≈ 7cqh → body ≈ 60cqh). 본 clamp
|
||||
은 .text-line 의 var(--lh-body) 를 override (cascade 우선순위). */
|
||||
.f14b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 7)), 1.6em);
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u5) P1 — aspect-ratio < 1.5 rotation rule. zone 의
|
||||
가로:세로 비가 1.5 미만으로 좁아지면 3-col grid 가 1-col stack 으로
|
||||
회전. Circle badge (.f14b__badge aspect-ratio 1/1) 는 col 내부 element
|
||||
이므로 회전 후에도 원형 유지. */
|
||||
@container f14b-root (aspect-ratio < 1.5) {
|
||||
.f14b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
.f14b__body .text-line--bullet::before {
|
||||
content: "\2713";
|
||||
position: absolute;
|
||||
@@ -226,7 +249,7 @@ Asset path runtime resolution :
|
||||
</div>
|
||||
|
||||
{# body — bullets with CSS check marker #}
|
||||
<div class="f14b__body">
|
||||
<div class="f14b__body" style="--max-body-lines: {{ (persona.body | length) if persona.body else 7 }};">
|
||||
{% if persona.body %}
|
||||
{% for line in persona.body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
|
||||
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
|
||||
Line 565 (IMP-29 frontend zone-level override) must remain untouched.
|
||||
The reject anchor previously referenced IMP-29 (frontend zone-level override); it has
|
||||
since been superseded by IMP-47B u1 (2026-05-21) which corrects the reject disposition
|
||||
to AI re-construction over the rank-1 reject frame.
|
||||
|
||||
Anchor re-pin (2026-05-20, IMP-30 u1 follow-up): V4Match.provisional field added at
|
||||
src/phase_z2_pipeline.py:179-184 shifted the route-hint table down by six lines.
|
||||
Pinned line numbers updated from 564/565 → 570/571 to track the actual anchor location.
|
||||
Pinned line numbers were updated 564/565 → 570/571.
|
||||
|
||||
Anchor re-pin (2026-05-22, IMP-36 u1 / Gitea #65 Stage 2): IMP-47B supersession at
|
||||
src/phase_z2_pipeline.py:579-582 expanded the reject hint comment by four lines, which
|
||||
shifted only the post-comment table downward. The restructure anchor itself moved from
|
||||
570 → 578 because additional comment context was inserted between the table header and
|
||||
the restructure line. Re-pinned 570 → 578 (restructure / IMP-17) and 571 → 579
|
||||
(reject / IMP-47B supersession of the prior IMP-29 reference).
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
|
||||
"""
|
||||
@@ -20,14 +29,17 @@ def _lines() -> list[str]:
|
||||
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_line_570_references_imp17_not_imp31():
|
||||
line = _lines()[569] # 1-indexed line 570
|
||||
assert "restructure" in line, f"line 570 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 570 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 570 must not reference non-existent IMP-31: {line!r}"
|
||||
def test_line_578_references_imp17_not_imp31():
|
||||
line = _lines()[577] # 1-indexed line 578
|
||||
assert "restructure" in line, f"line 578 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 578 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 578 must not reference non-existent IMP-31: {line!r}"
|
||||
|
||||
|
||||
def test_line_571_still_references_imp29():
|
||||
line = _lines()[570] # 1-indexed line 571
|
||||
assert "reject" in line, f"line 571 anchor drifted: {line!r}"
|
||||
assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {line!r}"
|
||||
def test_line_579_references_imp47b_supersession():
|
||||
line = _lines()[578] # 1-indexed line 579
|
||||
assert "reject" in line, f"line 579 anchor drifted: {line!r}"
|
||||
assert "IMP-47B" in line, (
|
||||
f"line 579 must reference IMP-47B (supersedes prior IMP-29 reject disposition): "
|
||||
f"{line!r}"
|
||||
)
|
||||
|
||||
166
tests/phase_z2/test_imp36_fit_rotation_generalization.py
Normal file
166
tests/phase_z2/test_imp36_fit_rotation_generalization.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""IMP-36 (Gitea #65) — P1/P2 fit/rotation generalization static checks.
|
||||
|
||||
Coupled with u2 (frame_contracts.yaml two-bool axis + F29 P3 parity).
|
||||
Asserts:
|
||||
(1) contract-level axis booleans on the 13 partial-backed contracts and
|
||||
their absence on the 19 builder-only contracts;
|
||||
(2) F29 P3 parity (both columns declare column_with_transform);
|
||||
(3) partial-side CSS signatures —
|
||||
P1 (rotation_eligible=true) → ``container-name: f<N>b-root`` +
|
||||
``container-type: size`` + ``@container <name> (aspect-ratio < 1.5)``.
|
||||
P2 (body_fit_pattern2=true) → ``--max-body-lines`` + ``cqh`` + ``clamp(``
|
||||
in the body line-height clamp.
|
||||
|
||||
Per Stage 2 plan the partial-side P1/P2 assertions for F13/F14/F20/F8 begin
|
||||
passing only after u4-u7 land. F23 (Stage 1 canonical P2 source) already
|
||||
satisfies P2 at u3 time. F23 explicitly stays P1=false (no rotation rule)
|
||||
per the in-file lock at templates/phase_z2/families/app_sw_package_vs_solution.html
|
||||
line 64.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONTRACTS_PATH = ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
FAMILIES_DIR = ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
EXPECTED_P1_TRUE = {
|
||||
"three_parallel_requirements",
|
||||
"three_persona_benefits",
|
||||
"dx_sw_necessity_three_perspectives",
|
||||
"info_management_what_how_when",
|
||||
}
|
||||
EXPECTED_P1_FALSE = {
|
||||
"app_sw_package_vs_solution",
|
||||
"bim_current_problems_paired",
|
||||
"bim_dx_comparison_table",
|
||||
"bim_issues_quadrant_four",
|
||||
"construction_bim_three_usage",
|
||||
"construction_goals_three_circle_intersection",
|
||||
"pre_construction_model_info_stacked",
|
||||
"process_product_two_way",
|
||||
"sw_reality_three_emphasis",
|
||||
}
|
||||
EXPECTED_P2_TRUE = EXPECTED_P1_TRUE | {"app_sw_package_vs_solution"}
|
||||
EXPECTED_P2_FALSE = EXPECTED_P1_FALSE - {"app_sw_package_vs_solution"}
|
||||
|
||||
# P1 container-name convention = f<frame_id>b-root, declared in Stage 2 plan.
|
||||
CONTAINER_NAMES = {
|
||||
"three_parallel_requirements": "f13b-root",
|
||||
"three_persona_benefits": "f14b-root",
|
||||
"dx_sw_necessity_three_perspectives": "f20b-root",
|
||||
"info_management_what_how_when": "f8b-root",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def contracts() -> dict:
|
||||
return yaml.safe_load(CONTRACTS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def partial_files() -> set[str]:
|
||||
return {p.stem for p in FAMILIES_DIR.glob("*.html")}
|
||||
|
||||
|
||||
# ─── contract metadata axis ────────────────────────────────────────────────
|
||||
def test_partial_backed_thirteen_carry_both_flags(contracts, partial_files):
|
||||
partial_backed = {k for k in contracts if k in partial_files}
|
||||
assert len(partial_backed) == 13, sorted(partial_backed)
|
||||
missing = [
|
||||
tid
|
||||
for tid in partial_backed
|
||||
if "rotation_eligible" not in contracts[tid]
|
||||
or "body_fit_pattern2" not in contracts[tid]
|
||||
]
|
||||
assert missing == [], missing
|
||||
bad_type = [
|
||||
tid
|
||||
for tid in partial_backed
|
||||
if not isinstance(contracts[tid]["rotation_eligible"], bool)
|
||||
or not isinstance(contracts[tid]["body_fit_pattern2"], bool)
|
||||
]
|
||||
assert bad_type == [], bad_type
|
||||
|
||||
|
||||
def test_builder_only_nineteen_carry_neither_flag(contracts, partial_files):
|
||||
builder_only = {k for k in contracts if k not in partial_files}
|
||||
assert len(builder_only) == 19, sorted(builder_only)
|
||||
leaked = [
|
||||
tid
|
||||
for tid in builder_only
|
||||
if "rotation_eligible" in contracts[tid] or "body_fit_pattern2" in contracts[tid]
|
||||
]
|
||||
assert leaked == [], leaked
|
||||
|
||||
|
||||
def test_rotation_eligible_true_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("rotation_eligible") is True}
|
||||
assert actual == EXPECTED_P1_TRUE
|
||||
|
||||
|
||||
def test_rotation_eligible_false_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("rotation_eligible") is False}
|
||||
assert actual == EXPECTED_P1_FALSE
|
||||
|
||||
|
||||
def test_body_fit_pattern2_true_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("body_fit_pattern2") is True}
|
||||
assert actual == EXPECTED_P2_TRUE
|
||||
|
||||
|
||||
def test_body_fit_pattern2_false_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("body_fit_pattern2") is False}
|
||||
assert actual == EXPECTED_P2_FALSE
|
||||
|
||||
|
||||
def test_f29_columns_both_with_transform(contracts):
|
||||
"""P3 parity — F29 (process_product_two_way) columns[*].body_parser symmetry."""
|
||||
cols = contracts["process_product_two_way"]["payload"]["builder_options"]["columns"]
|
||||
parsers = [c.get("body_parser") for c in cols]
|
||||
assert parsers == ["column_with_transform", "column_with_transform"], parsers
|
||||
|
||||
|
||||
# ─── partial-side CSS axis (u4-u7 progressively satisfy) ───────────────────
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P1_TRUE))
|
||||
def test_p1_partial_declares_aspect_ratio_rotation(tid):
|
||||
"""P1=true partials declare ``container-name``/``container-type`` and an
|
||||
``@container <name> (aspect-ratio < 1.5)`` rotation rule. Satisfied by
|
||||
F13/F14/F20/F8 in u4-u7."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
name = CONTAINER_NAMES[tid]
|
||||
assert f"container-name: {name}" in css, tid
|
||||
assert "container-type: size" in css, tid
|
||||
assert f"@container {name} (aspect-ratio < 1.5)" in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P1_FALSE))
|
||||
def test_p1_false_partial_has_no_rotation_rule(tid):
|
||||
"""P1=false partials must not declare ``aspect-ratio < 1.5`` rotation
|
||||
rule. F23 may still keep its own container-name for P2 cqh — only the
|
||||
rotation rule signature is forbidden here."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "aspect-ratio < 1.5" not in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P2_TRUE))
|
||||
def test_p2_partial_uses_cqh_clamp_max_body_lines(tid):
|
||||
"""P2=true partials declare ``--max-body-lines`` + ``cqh`` + ``clamp(``
|
||||
body line-height clamp. Satisfied by F23 today; F13/F14/F20/F8 land u4-u7."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "--max-body-lines" in css, tid
|
||||
assert "cqh" in css, tid
|
||||
assert "clamp(" in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P2_FALSE))
|
||||
def test_p2_false_partial_has_no_max_body_lines(tid):
|
||||
"""P2=false partials must not declare ``--max-body-lines``."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "--max-body-lines" not in css, tid
|
||||
299
tests/phase_z2/test_imp36_overflow_chain_self_fire.py
Normal file
299
tests/phase_z2/test_imp36_overflow_chain_self_fire.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""IMP-36 (Gitea #65 u8) — Selenium self-fire for the P1/P2 generalization.
|
||||
|
||||
For each of the four P1+P2 partials (F13 ``three_parallel_requirements``,
|
||||
F14 ``three_persona_benefits``, F20 ``dx_sw_necessity_three_perspectives``,
|
||||
F8 ``info_management_what_how_when``), the partial's ``<style>`` block is
|
||||
rendered with a minimal structural skeleton inside a fixed-size outer div
|
||||
at two aspect ratios — wide (1200x675, aspect 1.78) and tall (600x600,
|
||||
aspect 1.0) — and verified live in headless Chrome:
|
||||
|
||||
* P1 (container-query rotation): grid-template-columns goes from 3 tracks
|
||||
(wide, aspect >= 1.5) to 1 track (tall, aspect < 1.5).
|
||||
* P2 (cqh/clamp line-height): computed line-height on the body text element
|
||||
differs between wide and tall because ``cqh`` scales with container height.
|
||||
* P2 invariant (Stage 2 guardrail #6 / IMP-36 contract): the additive P2
|
||||
rule body declares ``line-height: clamp(...)`` only — no ``font-size``
|
||||
mutation. Enforced by static text scan of each partial.
|
||||
|
||||
OVERFLOW_CASCADE_ORDER must remain a 4-tuple — the Step 17 cascade contract
|
||||
is not altered by IMP-36 (P1/P2 are CSS-only self-fire; no new Python stage
|
||||
is introduced — "no new Python surface" per Stage 2 plan).
|
||||
|
||||
Chromedriver resolution mirrors the pipeline order (``PROJECT_ROOT/
|
||||
chromedriver{,.exe}`` -> PATH -> Selenium Manager). When no driver resolves
|
||||
the suite skips; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the skip becomes a
|
||||
strict xfail so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_ai_fallback.step17 import OVERFLOW_CASCADE_ORDER
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
FAMILIES = PROJECT_ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
# ─── chromedriver guard (mirrors test_phase_z2_step14_image_check) ───
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
for arg in ("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"):
|
||||
opts.add_argument(arg)
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── frame harness table ─────────────────────────────────────────────
|
||||
# stem = partial filename (no .html)
|
||||
# root = top-level container-query class (target of container-type:size)
|
||||
# cols = grid class that rotates 3->1 under aspect < 1.5
|
||||
# col_inner = minimal markup for one column with one body text element.
|
||||
# The inline --max-body-lines value is chosen so the P2 clamp
|
||||
# does not saturate at both aspects (otherwise wide and tall
|
||||
# would compute identical line-height). F13 uses 20cqh / N so
|
||||
# N=8 splits the clamp band; F14/F20/F8 use 60cqh / N so N=20
|
||||
# splits theirs.
|
||||
# text_sel = CSS selector for the body text element to measure
|
||||
# p2_re = regex for the IMP-36 P2 rule body (must contain line-height
|
||||
# clamp and must NOT contain font-size)
|
||||
#
|
||||
# Font-size invariance is asserted uniformly for all four frames — IMP-36 P2
|
||||
# mutates line-height / --max-body-lines only (Stage 2 guardrail #6).
|
||||
FRAMES = [
|
||||
{
|
||||
"stem": "three_parallel_requirements",
|
||||
"root": "f13b",
|
||||
"cols": "f13b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f13b__col"><div class="f13b__body">'
|
||||
'<div class="f13b__section"><div class="f13b__desc" '
|
||||
'style="--max-body-lines: 8;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div></div></div>"
|
||||
),
|
||||
"text_sel": ".f13b__desc",
|
||||
"p2_re": r"\.f13b__desc\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "three_persona_benefits",
|
||||
"root": "f14b",
|
||||
"cols": "f14b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f14b__col"><div class="f14b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f14b__body .text-line",
|
||||
"p2_re": r"\.f14b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "dx_sw_necessity_three_perspectives",
|
||||
"root": "f20b",
|
||||
"cols": "f20b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f20b__col"><div class="f20b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f20b__body .text-line",
|
||||
"p2_re": r"\.f20b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "info_management_what_how_when",
|
||||
"root": "f8b",
|
||||
"cols": "f8b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f8b__col"><div class="f8b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f8b__body .text-line",
|
||||
"p2_re": r"\.f8b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _read_style_block(partial: Path) -> str:
|
||||
text = partial.read_text(encoding="utf-8")
|
||||
m = re.search(r"<style>(.*?)</style>", text, flags=re.DOTALL)
|
||||
assert m, f"<style> block missing in {partial}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _harness_html(frame: dict, outer_w: int, outer_h: int) -> str:
|
||||
style = _read_style_block(FAMILIES / f"{frame['stem']}.html")
|
||||
cols_html = (
|
||||
f'<div class="{frame["cols"]}">' + (frame["col_inner"] * 3) + "</div>"
|
||||
)
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'><style>"
|
||||
":root{"
|
||||
" --font-body:10px; --font-sub-title:12px; --font-zone-title:13px;"
|
||||
" --font-caption:10px;"
|
||||
" --lh-body:1.4; --lh-sub-title:1.3; --lh-zone-title:1.3;"
|
||||
"}"
|
||||
"html,body{margin:0;padding:0;font-size:10px;}"
|
||||
f".outer{{width:{outer_w}px;height:{outer_h}px;}}"
|
||||
f".outer > .{frame['root']}{{width:100%;height:100%;}}"
|
||||
f"{style}</style></head><body>"
|
||||
f'<div class="outer"><div class="{frame["root"]}">'
|
||||
f"{cols_html}"
|
||||
"</div></div></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _new_driver():
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
opts = _Opts()
|
||||
for arg in ("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"):
|
||||
opts.add_argument(arg)
|
||||
drv_path = None
|
||||
for cand in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if cand.is_file():
|
||||
drv_path = str(cand)
|
||||
break
|
||||
if drv_path is None:
|
||||
which = shutil.which("chromedriver") or shutil.which("chromedriver.exe")
|
||||
if which:
|
||||
drv_path = which
|
||||
if drv_path:
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
return webdriver.Chrome(service=Service(executable_path=drv_path), options=opts)
|
||||
return webdriver.Chrome(options=opts)
|
||||
|
||||
|
||||
def _measure(drv, frame: dict, html_path: Path) -> dict:
|
||||
drv.get(html_path.resolve().as_uri())
|
||||
cols_tpl = drv.execute_script(
|
||||
"return getComputedStyle(document.querySelector(arguments[0])).gridTemplateColumns;",
|
||||
f".{frame['cols']}",
|
||||
)
|
||||
lh = drv.execute_script(
|
||||
"var el = document.querySelector(arguments[0]); "
|
||||
"return el ? getComputedStyle(el).lineHeight : null;",
|
||||
frame["text_sel"],
|
||||
)
|
||||
fs = drv.execute_script(
|
||||
"var el = document.querySelector(arguments[0]); "
|
||||
"return el ? getComputedStyle(el).fontSize : null;",
|
||||
frame["text_sel"],
|
||||
)
|
||||
tracks = [t for t in (cols_tpl or "").split() if t]
|
||||
return {"cols": cols_tpl, "tracks": len(tracks), "lh": lh, "fs": fs}
|
||||
|
||||
|
||||
# ─── live (Selenium) parametrized check ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", FRAMES, ids=[f["stem"] for f in FRAMES])
|
||||
def test_p1_rotation_and_p2_lineheight_self_fire(tmp_path: Path, frame: dict) -> None:
|
||||
"""P1: 3-track grid rotates to 1-track when aspect < 1.5.
|
||||
P2: line-height differs between aspects (cqh-driven clamp evaluates
|
||||
differently as container height changes).
|
||||
Font-size invariance is asserted uniformly for all four frames — IMP-36
|
||||
P2 mutates line-height / --max-body-lines only (Stage 2 guardrail #6)."""
|
||||
wide_path = tmp_path / f"{frame['stem']}_wide.html"
|
||||
tall_path = tmp_path / f"{frame['stem']}_tall.html"
|
||||
wide_path.write_text(_harness_html(frame, 1200, 600), encoding="utf-8")
|
||||
tall_path.write_text(_harness_html(frame, 400, 400), encoding="utf-8")
|
||||
|
||||
drv = _new_driver()
|
||||
try:
|
||||
drv.set_window_size(1400, 900)
|
||||
wide = _measure(drv, frame, wide_path)
|
||||
tall = _measure(drv, frame, tall_path)
|
||||
finally:
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert wide["tracks"] == 3, (frame["stem"], wide)
|
||||
assert tall["tracks"] == 1, (frame["stem"], tall)
|
||||
assert wide["lh"] is not None and tall["lh"] is not None, (frame["stem"], wide, tall)
|
||||
assert wide["lh"] != tall["lh"], (frame["stem"], wide, tall)
|
||||
assert wide["fs"] == tall["fs"], (frame["stem"], wide, tall)
|
||||
|
||||
|
||||
# ─── static (no-Selenium) guards ─────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", FRAMES, ids=[f["stem"] for f in FRAMES])
|
||||
def test_p2_rule_declares_line_height_only(frame: dict) -> None:
|
||||
"""IMP-36 P2 invariant — the additive P2 rule body must contain
|
||||
``line-height: clamp(`` and must NOT declare ``font-size``."""
|
||||
body = (FAMILIES / f"{frame['stem']}.html").read_text(encoding="utf-8")
|
||||
m = re.search(frame["p2_re"], body, flags=re.DOTALL)
|
||||
assert m, f"{frame['stem']}: P2 clamp rule not located via /{frame['p2_re']}/"
|
||||
rule_body = m.group(0)
|
||||
assert "line-height:" in rule_body, f"{frame['stem']}: P2 rule missing line-height: {rule_body!r}"
|
||||
assert "clamp(" in rule_body, f"{frame['stem']}: P2 rule missing clamp(: {rule_body!r}"
|
||||
assert "font-size" not in rule_body, (
|
||||
f"{frame['stem']}: P2 rule must not declare font-size — got {rule_body!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_overflow_cascade_order_is_four_tuple() -> None:
|
||||
"""IMP-36 must not alter the Step 17 cascade contract. P1/P2 are CSS-only
|
||||
self-fire (no new Python stage); the 4-tuple stays intact."""
|
||||
assert isinstance(OVERFLOW_CASCADE_ORDER, tuple)
|
||||
assert len(OVERFLOW_CASCADE_ORDER) == 4
|
||||
assert [stage.value for stage in OVERFLOW_CASCADE_ORDER] == [
|
||||
"deterministic",
|
||||
"popup",
|
||||
"ai_repair",
|
||||
"user_override",
|
||||
]
|
||||
Reference in New Issue
Block a user