Add read-only comp region map inspection

Expose original crops, foreground exclusions, container relationships and repeated code groups before asset production. Collect malformed geometry and fully masked references without changing specs, build state, approvals or scoring thresholds.

AI-assisted implementation and validation by Codex under maintainer direction.
This commit is contained in:
Paul Bakaus
2026-09-18 10:20:57 -07:00
parent af460886ee
commit 7709e87dc8
7 changed files with 584 additions and 4 deletions
+7 -3
View File
@@ -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 <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> 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 <png> --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 <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n");
io.out("usage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> 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 <png> --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --comp <png> --regions <json> --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 <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--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() {
+1
View File
@@ -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;
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Comp map inspection · Impeccable</title>
<style>
:root{color-scheme:light;--paper:#f6f7f5;--panel:#fff;--ink:#202623;--muted:#5b655f;--line:#d2d9d4;--accent:#00685f;--danger:#a6361d;--warn:#845413;font:14px/1.45 'Avenir Next',system-ui,sans-serif;color:var(--ink);background:var(--paper)}
*{box-sizing:border-box}body{margin:0}button,select,a{font:inherit}button,select{color:inherit;background:var(--panel);border:1px solid var(--line);border-radius:4px;padding:7px 11px;cursor:pointer}button:hover{background:#eaf1ed}button[aria-pressed=true]{background:var(--accent);border-color:var(--accent);color:white}a{color:var(--accent);text-underline-offset:3px}button:focus-visible,select:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}::selection{background:#c3e3d9}header{height:72px;display:flex;align-items:center;gap:24px;padding:12px 24px;background:var(--panel);box-shadow:0 2px 8px #17261b12;position:relative;z-index:2}h1{font-size:20px;line-height:1.3;margin:0}header p{margin:2px 0 0;color:var(--muted);font-size:12px}.links{margin-left:auto;display:flex;gap:16px;font-size:12px}.layout{display:grid;grid-template-columns:250px minmax(0,1fr) minmax(320px,.8fr);height:calc(100dvh - 72px)}nav{background:var(--panel);border-right:1px solid var(--line);overflow:auto;padding:14px 12px}.filters{display:flex;gap:4px;margin-bottom:12px}.filters button{flex:1;font-size:12px;padding:7px 3px}.region{display:grid;grid-template-columns:28px 1fr;gap:8px;text-align:left;width:100%;margin-bottom:3px;border-color:transparent;background:none}.region strong{display:block;font-size:13px;font-weight:600;overflow-wrap:anywhere}.region small{color:var(--muted);display:block;font-size:11px}.region[aria-current=true]{background:#e4eeea;border-color:#8dbbb0}.num{font-variant-numeric:tabular-nums;border:1px solid var(--line);border-radius:3px;text-align:center;padding:2px}.region.error .num{color:white;background:var(--danger);border-color:var(--danger)}.workspace{min-width:0;overflow:auto;padding:20px}.toolbar{display:flex;align-items:center;gap:10px;margin-bottom:14px}.toolbar strong{font-size:14px}.toolbar label{margin-left:auto;font-size:12px;color:var(--muted)}.map{position:relative;box-shadow:0 5px 18px #1c30251a;background:white}.map img{width:100%;display:block}.map svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.marker{cursor:pointer;outline:none}.marker rect{fill:transparent;stroke:var(--accent);stroke-width:1;vector-effect:non-scaling-stroke}.marker.selected rect,.marker:focus rect{stroke-width:3;fill:#00685f16}.marker.error rect{stroke:var(--danger)}.marker text{fill:white;font:600 12px system-ui}.marker .tag{fill:var(--accent);stroke:white;stroke-width:1}.marker.error .tag{fill:var(--danger)}.coverage{fill:#d6972620;stroke:#845413;stroke-width:1;stroke-dasharray:4 4;pointer-events:none}aside{overflow:auto;background:var(--panel);box-shadow:-3px 0 14px #1b30210c;padding:20px;border-left:1px solid var(--line)}h2{font-size:20px;line-height:1.25;margin:0 0 6px;overflow-wrap:anywhere}.meta{font-size:12px;color:var(--muted);margin:0 0 16px}.note{margin:8px 0 16px}.views{display:flex;gap:5px;flex-wrap:wrap;margin:12px 0}.views button{font-size:12px;padding:6px 9px}.preview{height:clamp(230px,38vh,520px);overflow:auto;background:#eef0ed;display:grid;place-items:center;position:relative}.preview img{display:block;max-width:none;max-height:none;object-fit:contain}.preview.fit img{width:100%;height:100%;min-height:0;min-width:0}.preview.native{display:block}.preview.native img{margin:auto}.caption{font-size:12px;color:var(--muted);margin:8px 0 16px}.issue{padding:10px 0;border-top:1px solid var(--line);font-size:13px}.issue b{font-weight:600;display:block}.issue.error b{color:var(--danger)}.issue.warning b{color:var(--warn)}details{margin:16px 0}summary{cursor:pointer;font-size:13px;font-weight:600}details p{font-size:12px;color:var(--muted);margin:7px 0}.empty{padding:16px;color:var(--muted)}#summary{padding:0 3px 12px;font-size:12px;color:var(--muted)}.legend{color:var(--muted);font-size:12px;margin:12px 0}.badge{color:var(--danger);font-weight:600}.map-note{max-width:65ch;color:var(--muted);font-size:12px}#zoom{padding:4px 6px}footer{font-size:12px;border-top:1px solid var(--line);padding-top:12px;color:var(--muted)}@media(max-width:1100px){.layout{grid-template-columns:210px minmax(0,1fr)}aside{grid-column:2;grid-row:2;overflow:visible}nav{grid-row:1 / 3}.layout{height:auto;min-height:calc(100dvh - 72px)}nav{position:sticky;top:0;height:calc(100dvh - 72px)}.workspace{overflow:visible}}@media(max-width:650px){header{height:auto;padding:14px;gap:12px}.links{flex-direction:column;gap:4px}.layout{display:block}.workspace,aside{padding:16px}nav{position:static;height:200px;border-bottom:1px solid var(--line)}h1{font-size:17px}.preview{height:300px}}
</style>
<header><div><h1>Comp map inspection</h1><p>Reference geometry · no build state or approvals changed</p></div><div class="links"><a href="overlay.png" target="_blank" rel="noopener">Numbered overlay</a><a href="report.json" target="_blank" rel="noopener">Report JSON</a></div></header>
<div class="layout"><nav aria-label="Regions"><div class="filters"><button id="all" aria-pressed="true">All regions</button><button id="problems" aria-pressed="false">Errors</button></div><div id="summary"></div><div id="list"></div></nav>
<main class="workspace"><div class="toolbar"><strong>Original comp</strong><label><input type="checkbox" id="coverage"> Uncovered cells</label></div><div class="map"><img src="comp.png" alt="Original approved comp"><svg id="map" role="group" aria-label="Region boundaries"></svg></div><p class="legend">Green: region boundary · rust: invalid reference · dashed: uncovered detail</p><p class="map-note">Select a region to inspect its exact crop. Overlaps may be intentional; this report shows geometry and mask effects, not a visual approval.</p><details id="global"><summary>Map diagnostics</summary><div id="global-issues"></div></details></main>
<aside aria-label="Selected region"><h2 id="title">Select a region</h2><p id="meta" class="meta"></p><p id="note" class="note"></p><div class="views"><button id="raw" aria-pressed="true">Original crop</button><button id="mask" aria-pressed="false">Matcher reference</button></div><div class="toolbar"><span id="dimensions" class="meta"></span><label>Zoom <select id="zoom"><option value="fit">Fit</option><option value="1">100%</option><option value="2">200%</option><option value="4">400%</option></select></label></div><div id="preview" class="preview fit"><img id="crop" alt=""></div><p id="caption" class="caption"></p><div id="issues"></div><details><summary>Relationships &amp; overlaps</summary><div id="relations"></div></details><footer>Reference crops must never ship as page assets. Repeated code groups keep every member; raster assets retain individual review.</footer></aside></div>
<script type="application/json" id="data">__REPORT_JSON__</script>
<script>
const data=JSON.parse(document.getElementById('data').textContent),$=id=>document.getElementById(id),svgNS='http://www.w3.org/2000/svg';
let selected=null,mode='raw',filter='all';
const errors=id=>data.issues.filter(i=>i.regionId===id&&i.severity==='error');
const add=(tag,text,cls)=>{const el=document.createElement(tag);el.textContent=text;if(cls)el.className=cls;return el};
function issueNodes(issues,target){target.replaceChildren();for(const i of issues){const item=add('div','',`issue ${i.severity}`);item.append(add('b',i.code.replaceAll('-',' ')),add('span',i.message));target.append(item)}}
function renderList(){const list=$('list');list.replaceChildren();for(const r of data.regions){if(filter==='problems'&&!errors(r.id).length)continue;const b=add('button','',`region ${errors(r.id).length?'error':''}`);b.setAttribute('aria-current',String(selected?.id===r.id));const text=add('span','');text.append(add('strong',r.id),add('small',`${r.kind}${r.container?' · container':''}${r.reviewGroup?' · '+r.reviewGroup:''}`));b.append(add('span',String(r.number),'num'),text);b.onclick=()=>select(r);list.append(b)}if(!list.children.length)list.append(add('p',filter==='problems'?'No drawable regions have errors. Check map diagnostics for rejected entries.':'No valid regions to display. Check map diagnostics.','empty'))}
function renderMap(){const svg=$('map');svg.setAttribute('viewBox',`0 0 ${data.compSize.width} ${data.compSize.height}`);svg.replaceChildren();const labelScale=data.compSize.width/Math.max(1,svg.getBoundingClientRect().width);if($('coverage').checked){for(const cell of data.uncoveredInkCells){const x=cell.charCodeAt(0)-65,y=Number(cell.slice(1));const rect=document.createElementNS(svgNS,'rect');Object.entries({x:x*data.compSize.width/10,y:y*data.compSize.height/10,width:data.compSize.width/10,height:data.compSize.height/10,class:'coverage'}).forEach(([k,v])=>rect.setAttribute(k,v));svg.append(rect)}}for(const r of data.regions){const g=document.createElementNS(svgNS,'g');g.setAttribute('class',`marker ${selected?.id===r.id?'selected':''} ${errors(r.id).length?'error':''}`);g.setAttribute('tabindex','0');g.setAttribute('role','button');g.setAttribute('aria-label',`Inspect ${r.number}: ${r.id}`);g.onclick=()=>select(r);g.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();select(r)}};const rect=document.createElementNS(svgNS,'rect');Object.entries({x:r.px.x,y:r.px.y,width:r.px.w,height:r.px.h}).forEach(([k,v])=>rect.setAttribute(k,v));g.append(rect);const tag=document.createElementNS(svgNS,'rect');Object.entries({x:r.px.x,y:r.px.y,width:24*labelScale,height:21*labelScale,class:'tag'}).forEach(([k,v])=>tag.setAttribute(k,v));const text=document.createElementNS(svgNS,'text');text.setAttribute('x',r.px.x+4*labelScale);text.setAttribute('y',r.px.y+15*labelScale);text.style.fontSize=`${12*labelScale}px`;text.textContent=r.number;g.append(tag,text);svg.append(g)}}
function preview(){if(!selected)return;const ref=mode==='mask'&&selected.referencePath;$('crop').src=ref||selected.cropPath;$('crop').alt=`${ref?'Matcher reference':'Original comp crop'} for ${selected.id}`;$('raw').setAttribute('aria-pressed',String(!ref));$('mask').setAttribute('aria-pressed',String(Boolean(ref)));$('mask').hidden=!selected.referencePath;const zoom=$('zoom').value;$('preview').className=`preview ${zoom==='fit'?'fit':'native'}`;$('crop').style.width=zoom==='fit'?'100%':`${selected.px.w*Number(zoom)}px`;$('crop').style.height=zoom==='fit'?'100%':`${selected.px.h*Number(zoom)}px`;$('caption').textContent=ref?`${(100*selected.reference.excludedFraction).toFixed(1)}% excluded by foreground regions. ${selected.reference.fullyExcluded?'No visible reference pixels remain.':'Filled areas are ignored during asset comparison.'}`:'Exact original pixels inside this region. Fit enlarges small crops; zoom cannot add missing image detail.'}
function select(r){selected=r;mode='raw';$('title').textContent=r.id;$('meta').textContent=`Region ${r.number} · ${r.kind}${r.container?' · container':''}`;$('note').textContent=r.note||'';$('dimensions').textContent=`${r.px.w} × ${r.px.h} px · x ${r.px.x}, y ${r.px.y}`;issueNodes(data.issues.filter(i=>i.regionId===r.id),$('issues'));$('relations').replaceChildren();const lines=[];if(r.parentId)lines.push(`Parent: ${r.parentId}`);if(r.reviewGroup)lines.push(`Review group: ${r.reviewGroup}${data.reviewGroups[r.reviewGroup]?' — '+data.reviewGroups[r.reviewGroup].join(', '):' (individual raster review)'}`);if(r.reference?.regions.length)lines.push('Masks: '+r.reference.regions.map(m=>m.id).join(', '));if(r.reference?.ignoredContainers.length)lines.push('Containers ignored as masks: '+r.reference.ignoredContainers.join(', '));for(const o of data.overlaps.filter(o=>o.a===r.id||o.b===r.id))lines.push(`${o.a===r.id?o.b:o.a}: ${o.pixels} px overlap · ${o.relationship}`);for(const line of lines)$('relations').append(add('p',line));if(!lines.length)$('relations').append(add('p','No declared relationships or overlaps.'));renderList();renderMap();preview()}
$('all').onclick=()=>{filter='all';$('all').setAttribute('aria-pressed','true');$('problems').setAttribute('aria-pressed','false');renderList()};$('problems').onclick=()=>{filter='problems';$('problems').setAttribute('aria-pressed','true');$('all').setAttribute('aria-pressed','false');renderList()};$('raw').onclick=()=>{mode='raw';preview()};$('mask').onclick=()=>{mode='mask';preview()};$('zoom').onchange=preview;$('coverage').onchange=renderMap;
$('summary').textContent=`${data.regions.length} of ${data.inputRegionCount} regions drawable · ${data.issues.filter(i=>i.severity==='error').length} errors`;
issueNodes(data.issues.filter(i=>!i.regionId||!data.regions.some(r=>r.id===i.regionId)),$('global-issues'));$('global').open=Boolean($('global-issues').children.length);renderList();renderMap();const initial=data.regions.find(r=>errors(r.id).length)||data.regions[0];if(initial)select(initial);else $('preview').hidden=true;new ResizeObserver(renderMap).observe(document.querySelector('.map'));
</script>
</html>
+536
View File
@@ -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<Value>, 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<String> {
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<Vec<f64>> = ["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<String, Value> = 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 <json>\n");
return 1;
};
let result = (|| -> Result<Value, String> {
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":"</script><script>alert(1)</script>","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::<u8>::new());
io.stderr = Box::new(Vec::<u8>::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("</script><script>alert(1)</script>"));
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());
}
}
+2
View File
@@ -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 <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 spans 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 <comp> --regions <file>`. 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 <text region>` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank <region> --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. <!-- rule:skill-comp-spec -->
Inspect the proposed map before producing assets: `impeccable comp-spec --comp <comp> --regions <file> --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 <id>` writes the reference; save `impeccable comp-spec --plate-prompt <id> --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 <plate> --prompt-file <prompt.txt>`. The API fallback is `impeccable generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png> --size <WxH> --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. <!-- rule:skill-human-component-review --> 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. <!-- rule:skill-plates-before-page -->
3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-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 `<img>`, 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. <!-- rule:skill-hero-gate -->
+9
View File
@@ -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.
+1 -1
View File
@@ -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 <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> 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 <png> --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 <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--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 <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> 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 <png> --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --comp <png> --regions <json> --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 <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n",
"stderr": "",
"exit": 0,
"signal": null,