diff --git a/scripts/smoke_frame_render.py b/scripts/smoke_frame_render.py index 3e903fb..45b03fc 100644 --- a/scripts/smoke_frame_render.py +++ b/scripts/smoke_frame_render.py @@ -221,6 +221,95 @@ def list_existing_partials() -> list[str]: return sorted(p.stem for p in FAMILIES_DIR.glob("*.html")) +# ─── Render-to artifact (R3 acceptance gate) ──────────────────── + + +def _extract_asset_refs(html: str, template_id: str) -> list[str]: + """Return relative paths `assets/{template_id}/` referenced by HTML. + + Matches both `src="assets/..."` (img tag) and `url("assets/...")` (CSS). + """ + import re + pattern = rf'(?:src=|url\()["\']?(assets/{re.escape(template_id)}/[^"\'\)\s]+)' + return sorted(set(re.findall(pattern, html))) + + +def render_to_dir(template_id: str, slot_payload: dict, out_dir: Path) -> tuple[bool, str]: + """R3 acceptance gate — render partial + copy assets + save artifact. + + Mechanism (Codex round 28 spec) : + 1. smoke render (StrictUndefined Jinja) → HTML + 2. reuse production `copy_assets(template_id, run_dir)` — assets//* 복사 + 3. save HTML to `{out_dir}/index.html` + 4. fail if HTML references a missing local asset (post copy) + 5. production render path 미변경 + + Returns (ok, summary_or_error). + """ + ok, html_or_err = smoke_render(template_id, slot_payload) + if not ok: + return False, f"render failed: {html_or_err}" + + out_dir.mkdir(parents=True, exist_ok=True) + + # Reuse production copy_assets (no logic dup) + import sys + sys.path.insert(0, str(PROJECT_ROOT)) + from src.phase_z2_pipeline import copy_assets + assets_dst = copy_assets(template_id, out_dir) + assets_info = f"assets dir={assets_dst.relative_to(out_dir) if assets_dst else '(none)'}" + + # Verify all referenced assets exist (Codex round 28 — fail-fast missing assets) + refs = _extract_asset_refs(html_or_err, template_id) + missing = [r for r in refs if not (out_dir / r).exists()] + if missing: + return False, ( + f"missing assets (fail-fast per Codex round 28) : {len(missing)} of " + f"{len(refs)} references not resolved : {missing[:3]}{'...' if len(missing) > 3 else ''}" + ) + + # Wrap partial with minimal HTML viewer (browser-openable) + viewer = f""" + + +F14 render artifact — {template_id} + + +
+{html_or_err} +
+
+ R3 acceptance gate artifact — smoke harness `--render-to`. template_id = {template_id}. + Open this file in a browser to visually inspect rendered output + promoted assets. +
+ +""" + + out_html = out_dir / "index.html" + out_html.write_text(viewer, encoding="utf-8") + + return True, ( + f"rendered → {out_html} ({len(html_or_err)} chars partial, " + f"{len(refs)} asset refs all resolved). {assets_info}" + ) + + # ─── CLI ──────────────────────────────────────────────────────── @@ -289,6 +378,11 @@ def main(argv: Optional[list[str]] = None) -> int: help="render every bundled fixture and report.") parser.add_argument("--payload", type=Path, default=None, help="JSON file with slot_payload (else stdin or fixture).") + parser.add_argument("--render-to", type=Path, default=None, metavar="DIR", + help=( + "R3 acceptance gate — render + copy_assets + save artifact " + "to DIR/index.html. fail-fast on missing local assets." + )) args = parser.parse_args(argv) if args.self_check: @@ -296,6 +390,28 @@ def main(argv: Optional[list[str]] = None) -> int: if args.template_id is None: parser.print_usage(sys.stderr) return 2 + + # --render-to mode (R3 acceptance gate) + if args.render_to is not None: + # Determine payload (fixture preferred for render-to) + if args.payload is not None: + payload = json.loads(args.payload.read_text(encoding="utf-8")) + elif args.template_id in SELF_CHECK_FIXTURES: + payload = SELF_CHECK_FIXTURES[args.template_id] + print(f"[info] using bundled fixture for {args.template_id}", file=sys.stderr) + elif not sys.stdin.isatty(): + payload = json.load(sys.stdin) + else: + print(f"error: no payload for '{args.template_id}' " + f"(--payload or stdin or bundled fixture required)", file=sys.stderr) + return 2 + ok, msg = render_to_dir(args.template_id, payload, args.render_to) + if ok: + print(f"PASS {args.template_id} → {msg}") + return 0 + print(f"FAIL {args.template_id} → {msg}", file=sys.stderr) + return 1 + return _cmd_one(args.template_id, args.payload) diff --git a/templates/phase_z2/catalog/frame_contracts.yaml b/templates/phase_z2/catalog/frame_contracts.yaml index f1ab9d9..3cab5e8 100644 --- a/templates/phase_z2/catalog/frame_contracts.yaml +++ b/templates/phase_z2/catalog/frame_contracts.yaml @@ -206,13 +206,16 @@ three_persona_benefits: - constructor - designer - # min_height_px : derive + confirm (Codex round 13 §2.2 + round 24 asset 분류). + # min_height_px : derive + confirm (Codex round 13 §2.2 + round 24 asset 분류 + # + round 26 self-consistency catch + round 28 M1 결정). # Figma frame 1927 px @ scale 0.49213 → 948 px adapted (full frame). # Phase Z slide-body ≤ 585 px → adaptive content fit. - # Content density 2nd refinement : badge raster 70 + bullet body ~210 + photo strip 36 + padding 30 - # = 346 (F29 class). 안전 buffer 포함 = **320**. confirm via V2 MDX 02 validation. + # Content density 2nd refinement (raster promoted) : + # badge raster 70 + bullet body 210 + photo strip 36 + padding 30 = 346 sum + # + 4 safety buffer = **350** (F29 345 와 동등 class). + # confirm via smoke harness `--render-to` artifact (browser visual inspection). visual_hints: - min_height_px: 320 + min_height_px: 350 accepted_content_types: - text_block