diff --git a/crates/comp-verbs/src/comp_spec.rs b/crates/comp-verbs/src/comp_spec.rs index 028e158a0..f3db1a80e 100644 --- a/crates/comp-verbs/src/comp_spec.rs +++ b/crates/comp-verbs/src/comp_spec.rs @@ -25,10 +25,10 @@ const COLS: &[u8] = b"ABCDEFGHIJ"; pub const MAX_CODE_REGION_AREA: f64 = 0.25; pub const EDGE_CONTACT_MIN: f64 = 0.35; -fn is_raster_kind(k: &str) -> bool { +pub(crate) fn is_raster_kind(k: &str) -> bool { matches!(k, "plate" | "image" | "texture") } -fn is_kind(k: &str) -> bool { +pub(crate) fn is_kind(k: &str) -> bool { matches!(k, "plate" | "image" | "texture" | "text" | "control" | "chrome" | "band") } @@ -910,7 +910,7 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { let spec_path = arg_or(argv, "spec", SPEC_PATH).to_string(); if flag(argv, "help") || argv.is_empty() { io.out("REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\n"); - io.out("usage: comp-spec.mjs --comp --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp --regions measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt [--background transparent|opaque|auto] the regeneration prompt for a raster region\n"); + io.out("usage: comp-spec.mjs --comp --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp --regions measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --comp --regions --inspect-map [--out-dir dir] [--json] inspect all crops and masks without writing a spec\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt [--background transparent|opaque|auto] the regeneration prompt for a raster region\n"); return 0; } if flag(argv, "print") { @@ -1012,6 +1012,10 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { } }; + if flag(argv, "inspect-map") { + return crate::map_inspection::run(argv, io, &comp, comp_path); + } + if flag(argv, "grid") { let grid_out = resolve(io, GRID_PATH); if let Some(parent) = grid_out.parent() { diff --git a/crates/comp-verbs/src/lib.rs b/crates/comp-verbs/src/lib.rs index a0b3d06d7..84fe7096c 100644 --- a/crates/comp-verbs/src/lib.rs +++ b/crates/comp-verbs/src/lib.rs @@ -18,6 +18,7 @@ pub mod build_phase; pub mod completion; pub mod comp_diff; pub mod comp_spec; +mod map_inspection; pub mod font_match; mod util; diff --git a/crates/comp-verbs/src/map_inspection.html b/crates/comp-verbs/src/map_inspection.html new file mode 100644 index 000000000..79a30a70a --- /dev/null +++ b/crates/comp-verbs/src/map_inspection.html @@ -0,0 +1,28 @@ + + + +Comp map inspection · Impeccable + +

Comp map inspection

Reference geometry · no build state or approvals changed

+
+
Original comp
Original approved comp

Green: region boundary · rust: invalid reference · dashed: uncovered detail

Select a region to inspect its exact crop. Overlaps may be intentional; this report shows geometry and mask effects, not a visual approval.

Map diagnostics
+
+ + + diff --git a/crates/comp-verbs/src/map_inspection.rs b/crates/comp-verbs/src/map_inspection.rs new file mode 100644 index 000000000..ec8e96dde --- /dev/null +++ b/crates/comp-verbs/src/map_inspection.rs @@ -0,0 +1,536 @@ +//! Read-only diagnostics for region authoring, before asset production. +use crate::{ + comp_spec::{self, is_kind, is_raster_kind}, + util::{arg, flag}, +}; +use impeccable_common::Io; +use impeccable_comp::{ + png_io, + raster::{self as r, Image}, +}; +use serde_json::{json, Value}; +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; + +fn issue(issues: &mut Vec, severity: &str, code: &str, id: Option<&str>, message: String) { + issues.push(json!({"severity":severity,"code":code,"regionId":id,"message":message})); +} + +// Inspection must not silently clamp malformed boxes or fall back to a band. +fn geometry_error(raw: &Value, comp: &Image) -> Option { + let formats = ["box", "pixelBox", "grid"] + .iter() + .filter(|k| raw.get(**k).is_some()) + .count(); + if formats != 1 { + return Some("Use exactly one of box, pixelBox, or grid.".into()); + } + for (key, width, height) in [ + ("box", 1., 1.), + ("pixelBox", comp.width as f64, comp.height as f64), + ] { + if let Some(b) = raw.get(key) { + let values: Option> = ["x", "y", "w", "h"] + .iter() + .map(|k| b[*k].as_f64()) + .collect(); + let Some(v) = values else { + return Some(format!("{key} requires numeric x, y, w, h.")); + }; + if v.iter() + .any(|n| !n.is_finite() || (key == "pixelBox" && n.fract() != 0.)) + || v[0] < 0. + || v[1] < 0. + || v[2] <= 0. + || v[3] <= 0. + || v[0] + v[2] > width + 1e-9 + || v[1] + v[3] > height + 1e-9 + { + return Some(format!( + "{key} must fit within {width} × {height}, with positive size{}.", + if key == "pixelBox" { + " and whole pixels" + } else { + "" + } + )); + } + if (v[2] / width * comp.width as f64).round() < 1. + || (v[3] / height * comp.height as f64).round() < 1. + { + return Some("Region rounds to less than one original comp pixel.".into()); + } + } + } + None +} + +fn inspect(comp: &Image, input: &Value, comp_path: &str) -> Value { + let mut issues = Vec::new(); + let mut valid = Vec::new(); + let mut measured = Vec::new(); + let empty = Vec::new(); + let raw_regions = input["regions"].as_array().unwrap_or(&empty); + let mut counts = HashMap::new(); + for raw in raw_regions { + if let Some(id) = raw["id"].as_str() { + *counts.entry(id).or_insert(0) += 1; + } + } + if raw_regions.is_empty() { + issue( + &mut issues, + "error", + "empty-map", + None, + "Provide a nonempty regions array.".into(), + ); + } + if input["draft"] == true { + issue( + &mut issues, + "warning", + "draft", + None, + "This is an unmeasured draft. Bands do not identify individual elements.".into(), + ); + } + for (index, raw) in raw_regions.iter().enumerate() { + let id = raw["id"].as_str(); + let mut invalid = false; + if id.is_none_or(|s| s.trim().is_empty()) { + issue( + &mut issues, + "error", + "missing-id", + None, + format!("Region {} needs an id.", index + 1), + ); + invalid = true; + } + if id.is_some_and(|s| counts.get(s).copied().unwrap_or(0) > 1) { + issue( + &mut issues, + "error", + "duplicate-id", + id, + "Duplicate id; every instance needs its own identity.".into(), + ); + invalid = true; + } + if !raw["kind"].as_str().is_some_and(is_kind) { + issue( + &mut issues, + "error", + "invalid-kind", + id, + "Specify plate, image, texture, text, control, chrome, or band.".into(), + ); + invalid = true; + } + if let Some(message) = geometry_error(raw, comp) { + issue(&mut issues, "error", "invalid-geometry", id, message); + invalid = true; + } + if invalid { + continue; + } + match comp_spec::measure_regions( + comp, + &json!({"regions":[raw],"allowUncovered":true}), + comp_path, + ) { + Err(message) => issue(&mut issues, "error", "measurement", id, message), + Ok(spec) => { + let mut region = spec["regions"][0].clone(); + region["number"] = json!(index + 1); + for key in ["parentId", "reviewGroup"] { + if let Some(value) = raw.get(key) { + region[key] = value.clone(); + } + } + measured.push(region); + valid.push(raw.clone()); + } + } + } + let mut spec = comp_spec::measure_regions( + comp, + &json!({"regions":valid,"allowUncovered":true}), + comp_path, + ) + .expect("individually validated regions"); + spec["regions"] = json!(measured); + let mut groups: serde_json::Map = serde_json::Map::new(); + for region in &mut measured { + let id = region["id"].as_str().unwrap().to_string(); + if let Some(parent) = region.get("parentId") { + let p = parent.as_str().and_then(|p| { + spec["regions"] + .as_array() + .unwrap() + .iter() + .find(|r| r["id"] == p) + }); + match p { + None => issue( + &mut issues, + "error", + "invalid-parent", + Some(&id), + "parentId must name an existing container.".into(), + ), + Some(p) if p["container"] != true || p["id"] == id || !contains(p, region) => { + issue( + &mut issues, + "error", + "invalid-parent", + Some(&id), + "Parent must be a distinct container enclosing this region.".into(), + ) + } + _ => {} + } + } + if let Some(group) = region.get("reviewGroup") { + if is_raster_kind(region["kind"].as_str().unwrap()) { + issue(&mut issues,"warning","raster-group",Some(&id),"Raster assets require individual review; this group does not combine their decisions.".into()); + } else if let Some(name) = group.as_str().filter(|n| !n.trim().is_empty()) { + groups + .entry(name) + .or_insert(json!([])) + .as_array_mut() + .unwrap() + .push(json!(id)); + } else { + issue( + &mut issues, + "error", + "invalid-group", + Some(&id), + "reviewGroup must be a nonempty name.".into(), + ); + } + } + if is_raster_kind(region["kind"].as_str().unwrap()) { + let reference = comp_spec::prepare_plate_reference(comp, &spec, region); + if let Some(message) = reference.issue(&id) { + issue(&mut issues, "error", "fully-masked", Some(&id), message); + } else if reference.excluded_pixels > 0 { + issue(&mut issues,"info","foreground-mask",Some(&id),format!("Foreground regions exclude {:.1}% of this reference. Inspect the crop and mask together.",100.*reference.excluded_pixels as f64/reference.total_pixels as f64)); + } + region["reference"] = reference.audit(); + } + } + // Parent cycles can exist even when equal-sized boxes enclose one another. + for region in &measured { + let mut seen = HashSet::new(); + let mut current = Some(region); + while let Some(r) = current { + if !seen.insert(r["id"].as_str().unwrap()) { + issue( + &mut issues, + "error", + "parent-cycle", + region["id"].as_str(), + "Container relationships contain a cycle.".into(), + ); + break; + } + current = r["parentId"] + .as_str() + .and_then(|id| measured.iter().find(|p| p["id"] == id)); + } + } + let mut overlaps = Vec::new(); + for (i, a) in measured.iter().enumerate() { + for b in &measured[i + 1..] { + let area = intersection(a, b); + if area > 0. { + overlaps.push(json!({"a":a["id"],"b":b["id"],"pixels":area, + "relationship": if a["container"]==true || b["container"]==true {"container extent"} else {"overlapping elements"}})); + } + } + } + for warning in spec["warnings"].as_array().unwrap() { + issue( + &mut issues, + "warning", + "measurement-warning", + None, + warning.as_str().unwrap_or_default().into(), + ); + } + let uncovered = &spec["uncoveredInkCells"]; + if !uncovered.as_array().unwrap().is_empty() { + issue(&mut issues,"warning","uncovered-ink",None,format!("{} grid cells have detail outside named regions. This is a coverage heuristic, not proof of missing components.",uncovered.as_array().unwrap().len())); + } + json!({"tool":"comp-spec inspect-map","version":1,"referenceOnly":true,"stateChanged":false, + "comp":comp_path,"compSize":{"width":comp.width,"height":comp.height},"inputRegionCount":raw_regions.len(), + "regions":measured,"issues":issues,"overlaps":overlaps,"reviewGroups":groups,"uncoveredInkCells":uncovered}) +} + +fn coord(region: &Value, key: &str) -> f64 { + region["px"][key].as_f64().unwrap_or(0.) +} +fn intersection(a: &Value, b: &Value) -> f64 { + ((coord(a, "x") + coord(a, "w")).min(coord(b, "x") + coord(b, "w")) + - coord(a, "x").max(coord(b, "x"))) + .max(0.) + * ((coord(a, "y") + coord(a, "h")).min(coord(b, "y") + coord(b, "h")) + - coord(a, "y").max(coord(b, "y"))) + .max(0.) +} +fn contains(a: &Value, b: &Value) -> bool { + intersection(a, b) >= coord(b, "w") * coord(b, "h") +} + +fn save_reference(path: &Path, image: &Image, source: &str) -> Result<(), String> { + let bytes = png_io::encode_png(image, &[("impeccable:crop-of".into(), source.into())])?; + std::fs::write(path, bytes).map_err(|e| e.to_string()) +} + +fn write_report(dir: &Path, comp: &Image, report: &mut Value) -> Result<(), String> { + // An inspection owns a new directory. Never overwrite an input, spec, or receipt. + if let Some(parent) = dir.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::create_dir(dir).map_err(|e| format!("choose a new output directory: {e}"))?; + let source = report["comp"].as_str().unwrap().to_string(); + save_reference(&dir.join("comp.png"), comp, &source)?; + let spec = report.clone(); + let mut overlay = comp.clone(); + for region in report["regions"].as_array_mut().unwrap() { + let n = region["number"].as_u64().unwrap(); + let crop = r::crop( + comp, + coord(region, "x"), + coord(region, "y"), + coord(region, "w"), + coord(region, "h"), + ); + let raw = format!("region-{n}.png"); + save_reference(&dir.join(&raw), &crop, &source)?; + region["cropPath"] = json!(raw); + if is_raster_kind(region["kind"].as_str().unwrap()) { + let reference = comp_spec::prepare_plate_reference(comp, &spec, region); + let file = format!("reference-{n}.png"); + save_reference(&dir.join(&file), &reference.image, &source)?; + region["referencePath"] = json!(file); + } + let color = if region.pointer("/reference/fullyExcluded") == Some(&json!(true)) { + [166., 54., 29., 255.] + } else { + [0., 104., 97., 255.] + }; + r::stroke_rect( + &mut overlay, + coord(region, "x"), + coord(region, "y"), + coord(region, "w"), + coord(region, "h"), + color, + 2., + ); + r::draw_label( + &mut overlay, + &n.to_string(), + coord(region, "x"), + coord(region, "y"), + [255., 255., 255., 255.], + color, + 2., + 3., + ); + } + save_reference(&dir.join("overlay.png"), &overlay, &source)?; + let data = serde_json::to_string_pretty(report).map_err(|e| e.to_string())?; + std::fs::write(dir.join("report.json"), &data).map_err(|e| e.to_string())?; + // JSON in a script element is data; escape HTML delimiters to prevent closing it. + let safe = data + .replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e"); + std::fs::write( + dir.join("index.html"), + include_str!("map_inspection.html").replace("__REPORT_JSON__", &safe), + ) + .map_err(|e| e.to_string()) +} + +pub fn run(argv: &[String], io: &mut Io, comp: &Image, comp_path: &str) -> i32 { + let Some(regions_path) = arg(argv, "regions") else { + io.err("inspect-map requires --regions \n"); + return 1; + }; + let result = (|| -> Result { + let bytes = std::fs::read(io.cwd.join(regions_path)).map_err(|e| e.to_string())?; + let input: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + let mut report = inspect(comp, &input, comp_path); + let default = format!( + ".impeccable/build/map-inspections/{}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + std::process::id() + ); + let output = arg(argv, "out-dir").unwrap_or(&default); + write_report(&io.cwd.join(output), comp, &mut report)?; + report["outputDir"] = json!(output); + Ok(report) + })(); + match result { + Err(e) => { + io.err(&format!("inspect-map: {e}\n")); + 1 + } + Ok(report) => { + let errors = report["issues"] + .as_array() + .unwrap() + .iter() + .filter(|i| i["severity"] == "error") + .count(); + if flag(argv, "json") { + io.out(&format!("{report}\n")); + } else { + io.out(&format!("MAP {}/index.html\nOVERLAY {}/overlay.png\n{} regions, {errors} errors. Reference only; no build state or approvals changed.\n",report["outputDir"].as_str().unwrap(),report["outputDir"].as_str().unwrap(),report["inputRegionCount"])); + for i in report["issues"].as_array().unwrap() { + io.out(&format!( + "{} {}: {}\n", + i["severity"].as_str().unwrap(), + i["regionId"].as_str().unwrap_or("map"), + i["message"].as_str().unwrap() + )); + } + } + if errors > 0 { + 2 + } else { + 0 + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use impeccable_comp::raster::create_image; + + #[test] + fn map_inspection_writes_only_new_reference_artifacts_and_escapes_labels() { + let root = std::env::temp_dir().join(format!( + "impeccable-map-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + let image = create_image(100, 100, [240, 240, 240, 255]); + let input = json!({"regions":[{"id":"","kind":"image","note":"room photograph","pixelBox":{"x":10,"y":10,"w":20,"h":20}}]}); + std::fs::write(root.join("regions.json"), input.to_string()).unwrap(); + std::fs::create_dir_all(root.join(".impeccable/build")).unwrap(); + std::fs::write(root.join(".impeccable/build/spec.json"), "existing spec").unwrap(); + std::fs::write(root.join(".impeccable/build/state.json"), "existing state").unwrap(); + let mut io = Io::stdio(); + io.cwd = root.clone(); + io.stdout = Box::new(Vec::::new()); + io.stderr = Box::new(Vec::::new()); + let args = [ + "--inspect-map", + "--regions", + "regions.json", + "--out-dir", + "inspection", + ] + .map(String::from); + assert_eq!(run(&args, &mut io, &image, "comp.png"), 0); + assert_eq!( + std::fs::read_to_string(root.join("regions.json")).unwrap(), + input.to_string() + ); + assert_eq!( + std::fs::read_to_string(root.join(".impeccable/build/spec.json")).unwrap(), + "existing spec" + ); + assert_eq!( + std::fs::read_to_string(root.join(".impeccable/build/state.json")).unwrap(), + "existing state" + ); + let html = std::fs::read_to_string(root.join("inspection/index.html")).unwrap(); + assert!(!html.contains("")); + for name in ["comp.png", "overlay.png", "region-1.png", "reference-1.png"] { + let bytes = std::fs::read(root.join("inspection").join(name)).unwrap(); + assert!(png_io::decode_png(&bytes) + .unwrap() + .text + .contains_key("impeccable:crop-of")); + } + assert_eq!( + run(&args, &mut io, &image, "comp.png"), + 1, + "existing report must not be overwritten" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn map_inspection_collects_invalid_geometry_and_duplicate_ids() { + let image = create_image(100, 100, [240, 240, 240, 255]); + let report = inspect( + &image, + &json!({"regions":[ + {"id":"outside", "kind":"image", "note":"room photograph", "pixelBox":{"x":90,"y":0,"w":20,"h":20}}, + {"id":"outside", "kind":"text", "note":"room heading", "box":{"x":0,"y":0,"w":2,"h":0.1}}, + {"id":"unknown", "kind":"imag", "note":"room photograph", "grid":"A0:B1"} + ]}), + "comp.png", + ); + let issues = report["issues"].as_array().unwrap(); + assert!(issues.len() >= 3, "{report}"); + assert!(issues.iter().any(|i| i["code"] == "duplicate-id")); + assert!(issues.iter().any(|i| i["code"] == "invalid-geometry")); + assert!(issues.iter().any(|i| i["code"] == "invalid-kind")); + assert!(report["regions"].as_array().unwrap().is_empty()); + } + + #[test] + fn map_inspection_exposes_masked_photos_without_hiding_group_members() { + let image = create_image(100, 100, [240, 240, 240, 255]); + let mut input = json!({"regions":[ + {"id":"room", "kind":"image", "note":"room photograph", "pixelBox":{"x":0,"y":0,"w":20,"h":20},"reviewGroup":"rooms"}, + {"id":"room-info", "kind":"control", "note":"room information", "pixelBox":{"x":0,"y":0,"w":20,"h":20}}, + {"id":"price", "kind":"text", "note":"room price label", "parentId":"room-info", "reviewGroup":"prices", "pixelBox":{"x":0,"y":0,"w":5,"h":5}}, + {"id":"price-2", "kind":"text", "note":"room price label", "reviewGroup":"prices", "pixelBox":{"x":30,"y":0,"w":5,"h":5}} + ]}); + let broken = inspect(&image, &input, "comp.png"); + assert_eq!(broken["regions"][0]["reference"]["fullyExcluded"], true); + assert!(broken["issues"] + .as_array() + .unwrap() + .iter() + .any(|i| i["code"] == "fully-masked")); + assert!(broken["issues"] + .as_array() + .unwrap() + .iter() + .any(|i| i["code"] == "raster-group")); + input["regions"][1]["container"] = json!(true); + let fixed = inspect(&image, &input, "comp.png"); + assert_eq!(fixed["regions"].as_array().unwrap().len(), 4); + assert_eq!(fixed["regions"][0]["reference"]["excludedPixels"], 25); + assert_eq!( + fixed["regions"][0]["reference"]["ignoredContainers"], + json!(["room-info"]) + ); + assert_eq!(fixed["reviewGroups"]["prices"], json!(["price", "price-2"])); + assert_eq!(fixed["regions"][2]["parentId"], "room-info"); + assert!(fixed.get("approved").is_none()); + } +} diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 6c270b2f4..d98a7b56f 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -108,6 +108,8 @@ Then, in order, each closed by `{{scripts_path}}/impeccable build-phase advance` The comp-led path is a frontier-tier job: it asks the builder to hold a measured layout, place plates at their boxes, and act on numeric readings across a dozen attempts. Smaller or faster models produce a recognisable page and stall under the hero gate; if the model in hand is one of those, say so before the direction round and take the code-led path, or expect the run to end at the hero with its readings unmet. 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions narrow to an ink cluster only when it retains at least 95% of the span’s contrasting pixels; inspect the resulting crop, especially for compound controls and multiline text; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. +Inspect the proposed map before producing assets: `impeccable comp-spec --comp --regions --inspect-map` writes a numbered overlay, exact crops, foreground-mask previews, and a consolidated report without changing the spec or build state. Inspect boundaries and excluded pixels together; an empty reference needs corrected geometry. Optional `parentId` names an enclosing `container` and `reviewGroup` identifies repeated code components for inspection; neither removes regions or approves assets. The report diagnoses geometry, not semantic completeness. + 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. After the initial assets exist, prepare the isolated code component previews and complete [component-review.md](component-review.md) before further gate-driven repair: the user reviews the whole component kit, including code, before page assembly. This checkpoint keeps the existing gates; it does not require passing them first. After the full page and responsive checks, use the assembled-hero checkpoint before the final response. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. 3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. diff --git a/tests/oracle/DELTAS.md b/tests/oracle/DELTAS.md index 69d646eba..7e3a20fd2 100644 --- a/tests/oracle/DELTAS.md +++ b/tests/oracle/DELTAS.md @@ -201,3 +201,12 @@ were compared unchanged. No frozen function vectors changed. New Rust regressions verify automatic drafts do not overwrite specs or existing drafts, cannot be submitted unchanged, and a rejected/missing region-source revision cannot advance the build using the last successful measurements. + +## Recorded 2026-09-18: read-only map inspection + +`comp-spec-usage` adds one help line for --inspect-map, --out-dir and --json. +Only that stdout line was edited; existing measurements and frozen function +vectors remain unchanged. Rust regressions cover consolidated invalid-input +findings, fully masked references, container and child masks, preservation of +review group members, reference PNG provenance, HTML escaping, and refusal to +overwrite an existing report. Inspection does not change specs or build state. diff --git a/tests/oracle/golden/comp-spec-usage.json b/tests/oracle/golden/comp-spec-usage.json index a3b119aa7..b344fe1b9 100644 --- a/tests/oracle/golden/comp-spec-usage.json +++ b/tests/oracle/golden/comp-spec-usage.json @@ -1,5 +1,5 @@ { - "stdout": "REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\nusage: comp-spec.mjs --comp --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp --regions measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt [--background transparent|opaque|auto] the regeneration prompt for a raster region\n", + "stdout": "REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\nusage: comp-spec.mjs --comp --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp --regions measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --comp --regions --inspect-map [--out-dir dir] [--json] inspect all crops and masks without writing a spec\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt [--background transparent|opaque|auto] the regeneration prompt for a raster region\n", "stderr": "", "exit": 0, "signal": null,