diff --git a/Front/client/src/components/SlideCanvas.tsx b/Front/client/src/components/SlideCanvas.tsx
index 87783d4..5e003c1 100644
--- a/Front/client/src/components/SlideCanvas.tsx
+++ b/Front/client/src/components/SlideCanvas.tsx
@@ -612,6 +612,28 @@ export default function SlideCanvas({
: null;
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
+ // IMP-11 u4: active frame lookup — distinct axis from preview.
+ // preview is shown only when override differs from default; active is
+ // always defined as override-if-present-else-default. Used by u5 to
+ // compare the active frame's catalog min_height_px against zone height.
+ const activeFrameId = overrideFrameId ?? defaultFrameId;
+ const activeCandidate = activeFrameId
+ ? region?.frame_candidates?.find((c) => c.id === activeFrameId)
+ : undefined;
+
+ // IMP-11 u5: catalog min_height_px violation hint. height is already
+ // a fraction of SLIDE_H (1280x720 logical px coordinate space), so
+ // logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
+ // not re-multiplied (double-apply would shrink the comparison value).
+ // Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
+ const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
+ const minHeightPx = activeCandidate?.minHeightPx ?? null;
+ const belowMinHeight =
+ isPendingLayout &&
+ minHeightPx != null &&
+ zoneHeightPx != null &&
+ zoneHeightPx < minHeightPx;
+
return (
)}
+ {/* IMP-11 u5: red border + 'min H Npx' badge when zone height
+ is below the active frame's catalog min_height_px. Visual
+ hint only, no clamp/resize behavior change. */}
+ {belowMinHeight && minHeightPx != null && (
+ <>
+
+
+ min H {minHeightPx}px
+
+ >
+ )}
+
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
부 라벨 = backend zone position (top, bottom, primary). */}
diff --git a/Front/client/src/services/designAgentApi.ts b/Front/client/src/services/designAgentApi.ts
index d7f4a61..b2ec5d0 100644
--- a/Front/client/src/services/designAgentApi.ts
+++ b/Front/client/src/services/designAgentApi.ts
@@ -527,6 +527,10 @@ export async function loadRun(runId: string): Promise {
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
catalogRegistered: c.catalog_registered,
+ // backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px).
+ // logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined.
+ // v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined (graceful).
+ minHeightPx: c.min_height_px ?? undefined,
}));
const displayStrategy = (
diff --git a/Front/client/src/types/designAgent.ts b/Front/client/src/types/designAgent.ts
index a6ff01b..5ca099f 100644
--- a/Front/client/src/types/designAgent.ts
+++ b/Front/client/src/types/designAgent.ts
@@ -127,6 +127,10 @@ export interface FrameCandidate {
/** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override
* 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */
catalogRegistered?: boolean;
+ /** IMP-11 D-2 — frame contract visual_hints.min_height_px (logical 1280x720 px).
+ * Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px.
+ * Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */
+ minHeightPx?: number;
}
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py
index d0b2205..56983ee 100644
--- a/src/phase_z2_pipeline.py
+++ b/src/phase_z2_pipeline.py
@@ -3969,6 +3969,24 @@ def run_phase_z2_mvp1(
"delegated_to": delegated,
})
+ # IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog
+ # frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px).
+ # None when contract unregistered (frontend tolerates undefined).
+ # Single get_contract lookup binds both catalog_registered and min_height_px.
+ v4_all_judgments_list = []
+ for c in v4_all_for_unit:
+ _contract = get_contract(c.template_id)
+ v4_all_judgments_list.append({
+ "template_id": c.template_id,
+ "frame_id": c.frame_id,
+ "frame_number": c.frame_number,
+ "v4_rank": c.v4_rank,
+ "confidence": c.confidence,
+ "label": c.label,
+ "catalog_registered": _contract is not None,
+ "min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"),
+ })
+
application_plan_units.append({
"unit_id": unit_id,
"layout_preset": layout_preset,
@@ -4004,18 +4022,8 @@ def run_phase_z2_mvp1(
# v4_all_judgments 는 reject 포함.
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
- "v4_all_judgments": [
- {
- "template_id": c.template_id,
- "frame_id": c.frame_id,
- "frame_number": c.frame_number,
- "v4_rank": c.v4_rank,
- "confidence": c.confidence,
- "label": c.label,
- "catalog_registered": get_contract(c.template_id) is not None,
- }
- for c in v4_all_for_unit
- ],
+ # IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered).
+ "v4_all_judgments": v4_all_judgments_list,
"application_candidates": app_candidates,
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
# additive fields. None / False / [] when no override CLI used.
diff --git a/tests/test_phase_z2_step9_v4_all_judgments_min_height.py b/tests/test_phase_z2_step9_v4_all_judgments_min_height.py
new file mode 100644
index 0000000..7aa1139
--- /dev/null
+++ b/tests/test_phase_z2_step9_v4_all_judgments_min_height.py
@@ -0,0 +1,169 @@
+"""IMP-11 D-2 (u1) — Step 9 v4_all_judgments[] min_height_px field tests.
+
+u1 contract:
+ Each v4_all_judgments[] entry MUST expose `min_height_px` sourced from
+ catalog `frame_contracts[template_id].visual_hints.min_height_px`
+ (logical 1280×720 px), with `None` fallback when contract is unregistered.
+ A single `get_contract(c.template_id)` lookup binds both
+ `catalog_registered` and `min_height_px` (no double-lookup cost).
+
+Production code = inline list builder in `run_phase_z2_mvp1`
+(`src/phase_z2_pipeline.py`, near v4_all_for_unit loop). These tests follow
+the same source-string + catalog-shape guard pattern as the existing
+`test_step9_production_emits_candidate_evidence_and_alias` in
+`tests/test_phase_z2_v4_fallback.py`, kept until a helper is extracted.
+"""
+from __future__ import annotations
+
+import inspect
+
+from src import phase_z2_pipeline
+from phase_z2_mapper import get_contract, load_frame_contracts
+
+
+# ─── Case 1 : u1 production-source guard ────────────────────────────────────
+
+
+def test_v4_all_judgments_emits_min_height_px_with_none_fallback():
+ """Source guard — single get_contract bound to `_contract`, then both
+ `catalog_registered` and `min_height_px` derived from that binding.
+ `min_height_px` uses the `(_contract or {})` chain so unregistered
+ contracts propagate `None` (frontend tolerates undefined).
+ """
+ source = inspect.getsource(phase_z2_pipeline)
+
+ # u1 marker present (locates the builder)
+ assert "IMP-11 D-2 (u1)" in source
+
+ # Single get_contract lookup bound to local var
+ assert "_contract = get_contract(c.template_id)" in source
+
+ # catalog_registered reuses the local binding (no second lookup)
+ assert '"catalog_registered": _contract is not None' in source
+
+ # min_height_px source = visual_hints chain; None when contract is None
+ assert (
+ '"min_height_px": (_contract or {})'
+ '.get("visual_hints", {})'
+ '.get("min_height_px")'
+ ) in source
+
+ # v4_all_judgments wires the new builder list
+ assert '"v4_all_judgments": v4_all_judgments_list' in source
+
+
+# ─── Case 2 : additive guarantee — existing 7 fields preserved ──────────────
+
+
+def test_v4_all_judgments_preserves_existing_fields():
+ """u1 is additive only — the existing 7 keys must remain in the per-entry
+ dict alongside the new `min_height_px`.
+ """
+ source = inspect.getsource(phase_z2_pipeline)
+
+ builder_start = source.find("IMP-11 D-2 (u1)")
+ assert builder_start != -1
+ builder_end = source.find("application_plan_units.append", builder_start)
+ assert builder_end != -1
+ builder = source[builder_start:builder_end]
+
+ for field in (
+ '"template_id": c.template_id',
+ '"frame_id": c.frame_id',
+ '"frame_number": c.frame_number',
+ '"v4_rank": c.v4_rank',
+ '"confidence": c.confidence',
+ '"label": c.label',
+ '"catalog_registered": _contract is not None',
+ '"min_height_px":',
+ ):
+ assert field in builder, f"missing field in u1 builder: {field!r}"
+
+
+# ─── Case 3 : catalog reality — visual_hints.min_height_px shape is real ────
+
+
+def test_catalog_visual_hints_min_height_px_path_is_real():
+ """The source-string guard depends on the actual catalog shape having
+ `visual_hints.min_height_px` as a positive int on registered contracts
+ whose `visual_hints` block declares it. Verify against the real
+ `frame_contracts.yaml` so a future catalog schema change cannot silently
+ invalidate the `.get("visual_hints", {}).get("min_height_px")` chain.
+ """
+ load_frame_contracts()
+
+ # Real registered template_ids that ship with visual_hints.min_height_px
+ # (verified via load_frame_contracts() — see frame_contracts.yaml).
+ sample_template_ids = (
+ "three_parallel_requirements",
+ "process_product_two_way",
+ "construction_goals_three_circle_intersection",
+ "bim_dx_comparison_table",
+ )
+
+ found = 0
+ for tid in sample_template_ids:
+ contract = get_contract(tid)
+ if contract is None:
+ continue # tolerate catalog rename — at least one must remain
+ # The exact .get chain used by the u1 builder
+ min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
+ assert isinstance(min_h, int), (
+ f"{tid}: visual_hints.min_height_px must be int, "
+ f"got {type(min_h).__name__}={min_h!r}"
+ )
+ assert min_h > 0, f"{tid}: min_height_px must be positive, got {min_h}"
+ found += 1
+
+ assert found > 0, (
+ "no sample registered contract present — catalog audit drift; "
+ "update sample_template_ids to match current frame_contracts.yaml"
+ )
+
+
+def test_registered_contract_without_min_height_px_propagates_none():
+ """Registered contract whose `visual_hints` block omits `min_height_px`
+ (or sets it to `null`) must also propagate `None` through the u1 chain.
+ Real example in current catalog: `bim_issues_quadrant_four`.
+ """
+ load_frame_contracts()
+
+ tid = "bim_issues_quadrant_four"
+ contract = get_contract(tid)
+ if contract is None:
+ import pytest # noqa: PLC0415 — runtime skip only when catalog drifts
+ pytest.skip(f"sample template {tid!r} no longer registered")
+
+ # Exact chain used by u1 builder
+ min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
+ assert min_h is None, (
+ f"{tid}: expected None when visual_hints.min_height_px is absent/null, "
+ f"got {min_h!r} — chain semantics changed"
+ )
+ # catalog_registered must still be True (additive, independent of value)
+ assert (contract is not None) is True
+
+
+# ─── Case 4 : None propagation for unregistered template_id ─────────────────
+
+
+def test_unregistered_template_id_propagates_none():
+ """When `get_contract(template_id)` returns `None`, the u1 chain
+ `(_contract or {}).get("visual_hints", {}).get("min_height_px")` must
+ yield `None` (frontend tolerates undefined; no KeyError).
+ """
+ load_frame_contracts()
+
+ # Synthetic template_id guaranteed not to be in the catalog
+ unregistered = "MOCK_template_unregistered_for_u1_test"
+ assert get_contract(unregistered) is None, (
+ "test precondition broken — synthetic template_id leaked into catalog"
+ )
+
+ # Replicate the u1 chain exactly
+ _contract = get_contract(unregistered)
+ min_height = (_contract or {}).get("visual_hints", {}).get("min_height_px")
+ catalog_registered = _contract is not None
+
+ assert min_height is None
+ assert catalog_registered is False