mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 09:06:53 +03:00
Fix review crop integrity and support repeated component decisions
This commit is contained in:
@@ -97,10 +97,10 @@ fn render_page(
|
||||
coords[3] * height as f64,
|
||||
];
|
||||
if let Some(isolated) = &isolated {
|
||||
let b = &isolated["bounds"];
|
||||
let b = &isolated["paintBounds"];
|
||||
let (x, y, w, h) = (b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["width"].as_f64().unwrap(), b["height"].as_f64().unwrap());
|
||||
if x >= clip[0] + clip[2] || y >= clip[1] + clip[3] || x + w <= clip[0] || y + h <= clip[1] {
|
||||
return Err("component target does not intersect its measured box".into());
|
||||
if x < clip[0] - 1. || y < clip[1] - 1. || x + w > clip[0] + clip[2] + 1. || y + h > clip[1] + clip[3] + 1. {
|
||||
return Err(format!("{}: review crop clips component content. Measured crop [{:.1}, {:.1}, {:.1}, {:.1}], visible content [{x:.1}, {y:.1}, {w:.1}, {h:.1}]. Check the reference region and component layout before asking the user to review it.", isolated["selector"].as_str().unwrap_or("component"), clip[0], clip[1], clip[2], clip[3]));
|
||||
}
|
||||
}
|
||||
let first = page
|
||||
@@ -390,6 +390,19 @@ mod tests {
|
||||
assert_eq!(impeccable_comp::png_io::decode_png(&crop).unwrap().image.data,pixels.data);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires Chromium"]
|
||||
fn isolated_capture_refuses_overflow_clipped_by_review_box() {
|
||||
let image=impeccable_comp::raster::create_image(200,100,[255,255,255,255]);
|
||||
let reference=impeccable_comp::png_io::encode_png(&image,&[]).unwrap();
|
||||
let html=br#"<!doctype html><style>html,body{margin:0}#nav{width:60px;height:40px}span{display:block;width:150px;height:20px;background:red}</style><nav id="nav"><span></span></nav>"#;
|
||||
let inputs=BTreeMap::from([("comp.png".into(),reference),("kit.html".into(),html.to_vec())]);
|
||||
let original=json!({"schemaVersion":2,"stage":"components","comp":{"url":"/files/comp.png","width":200,"height":100},"components":[{"id":"nav","box":{"x":0,"y":0,"w":0.3,"h":0.4},"preview":{"kind":"page","url":"/files/kit.html","selector":"#nav"},"dependencies":[]}]});
|
||||
let error=NativeComponentCapturer.capture(&mut original.clone(),&inputs).err().unwrap();
|
||||
assert!(error.contains("review crop clips component content"),"{error}");
|
||||
let mut valid=original;valid["components"][0]["box"]["w"]=json!(0.75);
|
||||
NativeComponentCapturer.capture(&mut valid,&inputs).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn component_crops_copy_verified_pixels_without_resizing_or_synthetic_edges() {
|
||||
let image = impeccable_comp::raster::Image {width:3,height:2,data:(0u8..24).collect()};
|
||||
let png = impeccable_comp::png_io::encode_png(&image, &[]).unwrap();
|
||||
|
||||
@@ -27,6 +27,28 @@
|
||||
main: owns ? computed.visibility : 'hidden',
|
||||
pseudo: ['::before','::after','::marker'].map(pseudo => owns ? getComputedStyle(element, pseudo).visibility : 'hidden') };
|
||||
});
|
||||
// Record visible layout/text extents before isolation. A small wrapper can
|
||||
// have overflowing children; its border box alone would certify a clipped crop.
|
||||
let paint = {left:rect.left,top:rect.top,right:rect.right,bottom:rect.bottom};
|
||||
function include(bounds, element) {
|
||||
let b = {left:bounds.left,top:bounds.top,right:bounds.right,bottom:bounds.bottom};
|
||||
for(let parent=element;parent;parent=parent.parentElement){
|
||||
const style=getComputedStyle(parent), clip=parent.getBoundingClientRect();
|
||||
if(/hidden|clip|scroll|auto/.test(style.overflowX)){b.left=Math.max(b.left,clip.left);b.right=Math.min(b.right,clip.right);}
|
||||
if(/hidden|clip|scroll|auto/.test(style.overflowY)){b.top=Math.max(b.top,clip.top);b.bottom=Math.min(b.bottom,clip.bottom);}
|
||||
}
|
||||
if(b.right<=b.left||b.bottom<=b.top)return;
|
||||
paint={left:Math.min(paint.left,b.left),top:Math.min(paint.top,b.top),right:Math.max(paint.right,b.right),bottom:Math.max(paint.bottom,b.bottom)};
|
||||
}
|
||||
elements.forEach((element,index)=>{
|
||||
if(!visibility[index].owns || visibility[index].main!=='visible')return;
|
||||
include(element.getBoundingClientRect(),element.parentElement);
|
||||
for(const node of element.childNodes){
|
||||
if(node.nodeType!==Node.TEXT_NODE||!node.textContent.trim())continue;
|
||||
const range=document.createRange();range.selectNodeContents(node);
|
||||
for(const bounds of range.getClientRects())include(bounds,element);
|
||||
}
|
||||
});
|
||||
// Apply only after reading all original computed styles. Descendant components
|
||||
// keep their layout space but cannot paint inside their parent's preview.
|
||||
if (document.querySelector('[data-impeccable-capture]')) throw Error('Reserved capture attribute is already present.');
|
||||
@@ -55,5 +77,6 @@
|
||||
const after = selected.element.getBoundingClientRect();
|
||||
if (['x','y','width','height'].some(key => Math.abs(rect[key] - after[key]) > .01)) throw Error('Isolating the component changed its layout.');
|
||||
return { method: 'dom-component-v1', selector: selected.selector, excludedComponents: otherRoots.map(root => root.id),
|
||||
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
|
||||
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
paintBounds: {x:paint.left,y:paint.top,width:paint.right-paint.left,height:paint.bottom-paint.top} };
|
||||
}
|
||||
|
||||
@@ -339,6 +339,29 @@ pub fn snap_box_to_ink(comp: &Image, boxf: (f64, f64, f64, f64), ground: f64) ->
|
||||
))
|
||||
}
|
||||
|
||||
/// Automatic narrowing must not discard separate words/lines from a compound
|
||||
/// control. The original largest-cluster helper remains available to explicit
|
||||
/// callers; region measurement uses this conservative wrapper.
|
||||
fn snap_preserving_ink(comp: &Image, boxf: (f64, f64, f64, f64), ground: f64) -> Option<(f64, f64, f64, f64)> {
|
||||
let snapped = snap_box_to_ink(comp, boxf, ground)?;
|
||||
let original = r::clamp_rect(comp, boxf.0 * comp.width as f64, boxf.1 * comp.height as f64,
|
||||
boxf.2 * comp.width as f64, boxf.3 * comp.height as f64);
|
||||
let keep = r::clamp_rect(comp, snapped.0 * comp.width as f64, snapped.1 * comp.height as f64,
|
||||
snapped.2 * comp.width as f64, snapped.3 * comp.height as f64);
|
||||
let (mut total, mut lost) = (0u64, 0u64);
|
||||
for y in original.y..original.y + original.h {
|
||||
for x in original.x..original.x + original.w {
|
||||
if (gray_no_alpha(&comp.data, (y * comp.width + x) * 4) - ground).abs() > 60. {
|
||||
total += 1;
|
||||
if x < keep.x || x >= keep.x + keep.w || y < keep.y || y >= keep.y + keep.h { lost += 1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
// At most incidental noise may disappear. Preserve the supplied span when
|
||||
// the algorithm cannot distinguish a second label from unrelated content.
|
||||
(lost * 100 <= total * 5).then_some(snapped)
|
||||
}
|
||||
|
||||
/// JS: uncoveredInkCells(comp, regions).
|
||||
fn uncovered_ink_cells(comp: &Image, regions: &[Value]) -> Vec<String> {
|
||||
let grid = m::detail_grid(comp, 10, 10, 512);
|
||||
@@ -453,7 +476,7 @@ pub fn measure_regions(comp: &Image, regions_input: &Value, comp_path: &str) ->
|
||||
let grid_str = raw.get("grid").and_then(Value::as_str);
|
||||
let snap_not_false = raw.get("snap").and_then(Value::as_bool) != Some(false);
|
||||
if !has_box && grid_str.is_some() && (kind == "text" || kind == "control") && snap_not_false {
|
||||
if let Some(snapped) = snap_box_to_ink(comp, boxf, page_ground) {
|
||||
if let Some(snapped) = snap_preserving_ink(comp, boxf, page_ground) {
|
||||
cover_box = Some(boxf);
|
||||
boxf = snapped;
|
||||
}
|
||||
@@ -1039,6 +1062,18 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
|
||||
mod reference_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automatic_snap_preserves_separated_navigation_and_multiline_copy() {
|
||||
let mut comp = r::create_image(300, 100, [255,255,255,255]);
|
||||
r::fill_rect(&mut comp, 25., 30., 70., 12., [0.,0.,0.,255.]);
|
||||
r::fill_rect(&mut comp, 185., 30., 45., 12., [0.,0.,0.,255.]);
|
||||
assert!(snap_box_to_ink(&comp, (0.,0.,1.,1.), 255.).is_some());
|
||||
assert!(snap_preserving_ink(&comp, (0.,0.,1.,1.), 255.).is_none());
|
||||
let mut single = r::create_image(300, 100, [255,255,255,255]);
|
||||
r::fill_rect(&mut single, 25., 30., 70., 12., [0.,0.,0.,255.]);
|
||||
assert!(snap_preserving_ink(&single, (0.,0.,1.,1.), 255.).is_some());
|
||||
}
|
||||
|
||||
fn fixture() -> (Image, Value) {
|
||||
let mut comp = r::create_image(16, 16, [230, 220, 210, 255]);
|
||||
r::fill_rect(&mut comp, 4., 4., 8., 8., [30., 70., 110., 255.]);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -147,6 +147,20 @@ pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap<String,
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut groups: BTreeMap<String, String> = BTreeMap::new();
|
||||
for c in input["components"].as_array().ok_or("components must be an array")? {
|
||||
if let Some(group) = c.get("reviewGroup") {
|
||||
let name = group.as_str().filter(|s| !s.trim().is_empty() && s.len() <= 120)
|
||||
.ok_or("reviewGroup needs a nonempty name of at most 120 bytes")?;
|
||||
if input["stage"] != "components" || c["preview"]["kind"] != "page" {
|
||||
return Err("review groups are for repeated code components; raster assets remain individual".into());
|
||||
}
|
||||
let path = string(&c["preview"], "path")?;
|
||||
if groups.insert(name.into(), path.into()).is_some_and(|previous| previous != path) {
|
||||
return Err("a review group must share one code document".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
let components = packet["components"]
|
||||
.as_array_mut()
|
||||
|
||||
@@ -641,3 +641,21 @@ fn visual_approvals_survive_shared_source_edits_but_not_changed_scope_or_pixels(
|
||||
let changed = store::read(&dir.join("current.json")).unwrap();
|
||||
assert!(changed["draft"]["decisions"]["control"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_groups_preserve_instances_and_require_a_shared_code_document() {
|
||||
let f = Fixture::new();
|
||||
fs::create_dir_all(f.project.join(".impeccable/build")).unwrap();
|
||||
fs::write(f.project.join(".impeccable/build/spec.json"), r#"{"regions":[]}"#).unwrap();
|
||||
let mut input=f.manifest(); input["stage"]=json!("components");
|
||||
input["components"][1]["reviewGroup"]=json!("Labels");
|
||||
let mut peer=input["components"][1].clone(); peer["id"]=json!("peer");
|
||||
input["components"].as_array_mut().unwrap().push(peer);
|
||||
let (packet, _) = manifest::freeze(&f.project, &input).unwrap();
|
||||
assert_eq!(packet["components"].as_array().unwrap().len(),3);
|
||||
assert_eq!(packet["components"][2]["reviewGroup"],"Labels");
|
||||
let mut invalid=input.clone();invalid["components"][0]["reviewGroup"]=json!("Labels");
|
||||
assert!(manifest::freeze(&f.project,&invalid).unwrap_err().contains("raster assets remain individual"));
|
||||
input["components"][2]["preview"]["path"]=json!("different.html");
|
||||
assert!(manifest::freeze(&f.project,&input).unwrap_err().contains("share one code document"));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ Use this checkpoint on comp-led builds after producing the initial component kit
|
||||
|
||||
Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing.
|
||||
|
||||
For a repeated code pattern, give its instances the same `reviewGroup` name. Keep every instance and its region ID in the manifest, in the same kit document. The user can inspect instances and explicitly apply one decision to the unreviewed group. Unique raster assets still require their own review; grouping never removes inventory or gate checks.
|
||||
|
||||
Before producing assets, inspect each reference crop against its named subject. Coarse grid cells and automatic ink snapping can include neighbors or omit parts of a compound element. Correct the measured region with an explicit normalized `box`; do not build to a known bad crop. Check the code preview contains the complete component before presenting it. The capture tool refuses content cut off by the review crop.
|
||||
|
||||
Write `.impeccable/review/components.json` with this manifest format:
|
||||
|
||||
```json
|
||||
@@ -23,7 +27,7 @@ Write `.impeccable/review/components.json` with this manifest format:
|
||||
"box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7},
|
||||
"note": "Produced cutout; positioned over the page ground.",
|
||||
"preview": {"kind": "image", "path": "assets/illustration.png"},
|
||||
"dependencies": [".impeccable/build/spec.json"]
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "headline",
|
||||
@@ -32,13 +36,13 @@ Write `.impeccable/review/components.json` with this manifest format:
|
||||
"box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25},
|
||||
"note": "Rendered semantic heading and its typography.",
|
||||
"preview": {"kind": "page", "path": ".impeccable/review/components/kit.html", "selector": "#headline"},
|
||||
"dependencies": [".impeccable/build/spec.json", "assets/type.woff2"]
|
||||
"dependencies": ["assets/type.woff2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). Each code preview requires a `selector` matching exactly one component element inside the document body. Shared kit documents are supported: the native capturer preserves layout and authored styles, hides other components, and crops to the measured box. A separately targeted child is excluded from its parent's isolated preview. Background fields therefore show their own paint, not the text and controls laid over them. Place components at the comp coordinates in the review document.
|
||||
The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (the spec’s `box` is already normalized; divide only pixel coordinates by comp dimensions). Each code preview requires a `selector` matching exactly one component element inside the document body. Shared kit documents are supported: the native capturer preserves layout and authored styles, hides other components, and crops to the measured box. A separately targeted child is excluded from its parent's isolated preview. Background fields therefore show their own paint, not the text and controls laid over them. Place components at the comp coordinates in the review document.
|
||||
|
||||
For a raster placed inside the kit, add `context: {"kind":"page","path":".impeccable/review/components/kit.html","selector":"#illustration"}` and declare that document's dependencies. This identifies its DOM placement so a containing code component excludes it too; the raster preview remains the original image bytes.
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Then, in order, each closed by `{{scripts_path}}/impeccable build-phase advance`
|
||||
0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened.
|
||||
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 snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `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 -->
|
||||
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 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 <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 -->
|
||||
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 -->
|
||||
|
||||
@@ -72,3 +72,17 @@ test('reviewed queue includes feedback, pending queue includes stale decisions',
|
||||
expect(packet.components.filter(c=>inReviewQueue(c,draft,'pending')).map(c=>c.id)).toEqual(['art']);
|
||||
expect(packet.components.filter(c=>inReviewQueue(c,draft,'reviewed')).map(c=>c.id)).toEqual(['control']);
|
||||
});
|
||||
|
||||
test('explicit pattern decisions preserve prior decisions, raster reviews and open edits', async () => {
|
||||
const {reviewPeers,decisionTargets}=await import('./model');
|
||||
const pattern={...packet.components[1],reviewGroup:'Room labels'};
|
||||
const p={...packet,components:[{...packet.components[0],reviewGroup:'Room labels'},pattern,
|
||||
...['b','c','d'].map(id=>({...pattern,id,revision:id}))]};
|
||||
const draft=newDraft(p);
|
||||
draft.decisions.b={revision:'b',action:'revise',feedback:'Keep this specific repair',split:false};
|
||||
expect(reviewPeers(p,pattern).map(c=>c.id)).toEqual(['control','b','c','d']);
|
||||
expect(decisionTargets(p,draft,pattern,false).map(c=>c.id)).toEqual(['control']);
|
||||
expect(decisionTargets(p,draft,pattern,true,['c']).map(c=>c.id)).toEqual(['control','d']);
|
||||
expect(draft.decisions.b.feedback).toBe('Keep this specific repair');
|
||||
expect(reviewPeers(p,packet.components[0])).toEqual([packet.components[0]]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type Box = { x: number; y: number; w: number; h: number };
|
||||
export type Component = {
|
||||
id: string; revision: string; name: string; medium: string; note: string; box: Box;
|
||||
reviewGroup?: string;
|
||||
material?: { format: string; width: number; height: number; alpha: 'transparent' | 'opaque' | 'unknown' };
|
||||
context?: { kind?: 'image' | 'page'; sourceKind?: 'page'; url: string; layering: string };
|
||||
thumbnail?: { url: string; box?: Box };
|
||||
@@ -96,3 +97,14 @@ export type InventoryFilter = 'pending' | 'reviewed' | 'all';
|
||||
export function inReviewQueue(component: Component, draft: Draft, filter: InventoryFilter) {
|
||||
return filter === 'all' || (componentState(component, draft).kind === 'pending') === (filter === 'pending');
|
||||
}
|
||||
|
||||
/** Grouping is authored explicitly, never guessed from names or visual similarity.
|
||||
* Decisions remain per component; existing decisions and unsaved edits are excluded. */
|
||||
export function reviewPeers(packet: ReviewPacket, component: Component): Component[] {
|
||||
if (!component.reviewGroup || !componentPresentation(component).code) return [component];
|
||||
return packet.components.filter(c => c.reviewGroup === component.reviewGroup && componentPresentation(c).code);
|
||||
}
|
||||
export function decisionTargets(packet: ReviewPacket, draft: Draft, selected: Component, grouped: boolean, editing: string[] = []) {
|
||||
return (grouped ? reviewPeers(packet, selected) : [selected]).filter(c => c.id === selected.id ||
|
||||
(componentState(c, draft).kind === 'pending' && !editing.includes(c.id)));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { inReviewQueue, type InventoryFilter, componentPresentation, nextUnreviewed, approveRemaining, componentState, repairStatus, newDraft, submission, summarize, type Box, type Decision, type Draft, type ReviewPacket, type ReviewHistory } from './model';
|
||||
import { reviewPeers, decisionTargets, inReviewQueue, type InventoryFilter, componentPresentation, nextUnreviewed, approveRemaining, componentState, repairStatus, newDraft, submission, summarize, type Box, type Decision, type Draft, type ReviewPacket, type ReviewHistory } from './model';
|
||||
import { comparisonSize, hoverPan } from './viewport';
|
||||
import { styles } from './styles';
|
||||
import { icon } from './icons';
|
||||
@@ -27,7 +27,8 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
let error = '';
|
||||
const edits: Record<string, {feedback: string; split: boolean}> = {};
|
||||
let finished = !!options.completed || !summarize(packet,draft).pending;
|
||||
let lastDecision: {id: string; name: string; action: 'approve' | 'revise'; previous?: Decision} | null = null;
|
||||
let lastDecision: {id: string; name: string; action: 'approve' | 'revise'; previous: Record<string, Decision | undefined>} | null = null;
|
||||
let applyGroup = false;
|
||||
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘Enter' : 'Ctrl+Enter';
|
||||
let overlay = false;
|
||||
let expandedComparison = false;
|
||||
@@ -57,7 +58,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
const next = nextUnreviewed(packet, draft, after);
|
||||
finished = !next;
|
||||
if (next) selected = next;
|
||||
mobilePane='component'; previousRound=false; overlay=false; zoom='fit'; outputMode='isolated';
|
||||
mobilePane='component'; previousRound=false; overlay=false; zoom='fit'; outputMode='isolated'; applyGroup=false;
|
||||
inventoryFilter = finished ? 'reviewed' : 'pending';
|
||||
const showNext = () => {
|
||||
render();
|
||||
@@ -81,9 +82,10 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
if (!c) return;
|
||||
const saved = draft.decisions[c.id];
|
||||
const current = saved?.revision===c.revision ? saved : undefined;
|
||||
lastDecision = {id:c.id, name:c.name, action, previous:saved ? {...saved} : undefined};
|
||||
const targets = decisionTargets(packet,draft,c,applyGroup,Object.keys(edits));
|
||||
lastDecision = {id:c.id, name:targets.length>1 ? `${c.reviewGroup} · ${targets.length} instances` : c.name, action, previous:Object.fromEntries(targets.map(t=>[t.id,draft.decisions[t.id] ? {...draft.decisions[t.id]} : undefined]))};
|
||||
const note = edits[c.id] ?? current;
|
||||
draft.decisions[c.id] = {revision:c.revision, action, feedback:action==='revise' ? note?.feedback ?? '' : '', split:action==='revise' && (note?.split ?? false)};
|
||||
for (const target of targets) draft.decisions[target.id] = {revision:target.revision, action, feedback:action==='revise' ? note?.feedback ?? '' : '', split:action==='revise' && (note?.split ?? false)};
|
||||
delete edits[c.id];
|
||||
if(restoreTrayAfterFeedback){trayOpen=true;restoreTrayAfterFeedback=false;}
|
||||
if (assembled) {
|
||||
@@ -139,7 +141,9 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
const d = savedDecision?.revision === c?.revision ? savedDecision : undefined;
|
||||
const edit = c ? edits[c.id] : undefined;
|
||||
const uncommitted = Object.keys(edits).length > 0;
|
||||
const isLast = c ? !packet.components.some(item=>item.id!==c.id && componentState(item,draft).kind==='pending') : false;
|
||||
const peers = c ? reviewPeers(packet,c) : [];
|
||||
const targets = c ? decisionTargets(packet,draft,c,applyGroup,Object.keys(edits)) : [];
|
||||
const isLast = c ? !packet.components.some(item=>!targets.some(t=>t.id===item.id) && componentState(item,draft).kind==='pending') : false;
|
||||
const notice = lastDecision && !submitted ? `<div class="decision-notice"><span role="status">${esc(lastDecision.name)} ${lastDecision.action==='approve'?'approved':'flagged for repair'}.</span><button id="undo-decision" class="quiet">Undo</button></div>` : '';
|
||||
const stats = summarize(packet, draft);
|
||||
const history = options.history;
|
||||
@@ -200,7 +204,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
${repair?.change?.kind==='changed'?`<details class="changed-files" ${filesOpen?'open':''}><summary>${repair.change.files.length?`${repair.change.files.length} changed ${repair.change.files.length===1?'file':'files'}`:repair.change.reasons.includes('region')?'Region changed':priorComponent?.note!==c.note?'Description changed · files unchanged':'Component definition changed · files unchanged'}</summary>${repair.change.files.length?`<ul>${repair.change.files.map(path=>`<li>${esc(path)}</li>`).join('')}</ul>`:''}${priorComponent&&priorComponent.note!==c.note?`<dl class="description-diff"><dt>Previous description</dt><dd>${esc(priorComponent.note)}</dd><dt>Current description</dt><dd>${esc(c.note)}</dd></dl>`:''}</details>`:''}
|
||||
</div>`:''}
|
||||
|
||||
<div class="comparison-slot"><div class="comparison-panel"><h2 class="expanded-title">${esc(v!.name)}</h2><div class="compare-toolbar">${priorComponent?`<div class="round-switch" role="group" aria-label="Preview version"><button id="current-round" aria-label="Current · round ${packet.round}" title="Current · round ${packet.round}" aria-pressed="${!viewingPrevious}">Current</button><button id="previous-round" aria-label="Previous · round ${history!.packet.round}" title="Previous · round ${history!.packet.round}" aria-pressed="${viewingPrevious}">Previous</button></div>`:''}<label class="zoom-control" title="Comparison zoom · based on comp pixels">${icon('zoom')}<select id="zoom" aria-label="Comparison zoom">${[['fit','Fit'],['1','100%'],['2','200%'],['4','400%']].map(([value,label])=>`<option value="${value}" ${String(zoom)===value?'selected':''}>${label}</option>`).join('')}</select>${icon('chevronDown')}</label><button id="overlay" class="overlay-control" aria-label="Overlay comp" title="Overlay approved comp" aria-pressed="${overlay}"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="10" height="10"/><rect x="7" y="7" width="10" height="10"/></svg><span class="overlay-label">Overlay</span></button><div class="comparison-actions" role="group" aria-label="Comparison view actions"><button id="expand-comparison" class="icon-button" aria-label="${expandedComparison?'Restore comparison':'Enlarge comparison'}" title="${expandedComparison?'Restore comparison (Esc)':'Enlarge comparison'}" aria-expanded="${expandedComparison}">${icon(expandedComparison?'compact':'expand')}</button>${v?.preview.kind==='image'?`<a class="icon-button source-link" href="${url(sourceUrl!)}" target="_blank" rel="noopener" aria-label="${useContext?'Open context capture':presentation!.fileLabel}" title="${useContext?'Open context capture':presentation!.fileLabel}">${icon('external')}</a>`:''}</div></div>
|
||||
<div class="comparison-slot"><div class="comparison-panel"><h2 class="expanded-title">${esc(v!.name)}</h2>${peers.length>1?`<div class="review-peers"><strong>${esc(c!.reviewGroup!)} <span>· ${peers.length} instances</span></strong><div role="group" aria-label="Inspect matching components">${peers.map(peer=>`<button data-select="${esc(peer.id)}" aria-pressed="${peer.id===c!.id}" title="${esc(peer.name)}">${packet.components.indexOf(peer)+1}</button>`).join('')}</div><label><input id="apply-group" type="checkbox" ${applyGroup?'checked':''} ${submitted||viewingPrevious?'disabled':''}>Apply this decision to ${decisionTargets(packet,draft,c!,true,Object.keys(edits)).length} unreviewed instances</label></div>`:''}<div class="compare-toolbar">${priorComponent?`<div class="round-switch" role="group" aria-label="Preview version"><button id="current-round" aria-label="Current · round ${packet.round}" title="Current · round ${packet.round}" aria-pressed="${!viewingPrevious}">Current</button><button id="previous-round" aria-label="Previous · round ${history!.packet.round}" title="Previous · round ${history!.packet.round}" aria-pressed="${viewingPrevious}">Previous</button></div>`:''}<label class="zoom-control" title="Comparison zoom · based on comp pixels">${icon('zoom')}<select id="zoom" aria-label="Comparison zoom">${[['fit','Fit'],['1','100%'],['2','200%'],['4','400%']].map(([value,label])=>`<option value="${value}" ${String(zoom)===value?'selected':''}>${label}</option>`).join('')}</select>${icon('chevronDown')}</label><button id="overlay" class="overlay-control" aria-label="Overlay comp" title="Overlay approved comp" aria-pressed="${overlay}"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="10" height="10"/><rect x="7" y="7" width="10" height="10"/></svg><span class="overlay-label">Overlay</span></button><div class="comparison-actions" role="group" aria-label="Comparison view actions"><button id="expand-comparison" class="icon-button" aria-label="${expandedComparison?'Restore comparison':'Enlarge comparison'}" title="${expandedComparison?'Restore comparison (Esc)':'Enlarge comparison'}" aria-expanded="${expandedComparison}">${icon(expandedComparison?'compact':'expand')}</button>${v?.preview.kind==='image'?`<a class="icon-button source-link" href="${url(sourceUrl!)}" target="_blank" rel="noopener" aria-label="${useContext?'Open context capture':presentation!.fileLabel}" title="${useContext?'Open context capture':presentation!.fileLabel}">${icon('external')}</a>`:''}</div></div>
|
||||
<div class="compare">
|
||||
<figure><figcaption>${viewingPrevious ? `Comp · Round ${history!.packet.round}` : assembled ? 'Approved comp' : 'In the comp'}</figcaption><div class="pan-viewport" aria-label="Reference comparison canvas" tabindex="0"><div class="crop-stage"><img class="crop-image" src="${url(vp.comp.url)}" alt="Reference region for ${esc(v!.name)}" style="width:${100/v!.box.w}%;left:${-100*v!.box.x/v!.box.w}%;top:${-100*v!.box.y/v!.box.h}%"></div></div></figure>
|
||||
<figure><figcaption>${viewingPrevious ? `Previous · Round ${history!.packet.round}` : assembled ? 'Assembled page' : useContext ? 'In context' : history ? `${presentation!.caption} · Round ${packet.round}` : presentation!.caption}</figcaption><div class="pan-viewport" aria-label="Produced comparison canvas" tabindex="0"><div class="output crop-stage ${hasTransparency&&!useContext&&!useFrame&&backdrop==='checker'?'checker':''}">${!useFrame ? `<img class="asset" src="${url(sourceUrl!)}" alt="Produced ${esc(v!.name)}" style="object-position:${esc(v!.preview.position ?? 'center')}">` : `<iframe aria-hidden="true" title="Rendered ${esc(v!.name)}" src="${url(sourceUrl!)}" sandbox="" tabindex="-1" width="${vp.comp.width}" height="${vp.comp.height}"></iframe>`}${overlay ? `<img class="crop-image overlay-image" src="${url(vp.comp.url)}" alt="Reference overlay" style="width:${100/v!.box.w}%;left:${-100*v!.box.x/v!.box.w}%;top:${-100*v!.box.y/v!.box.h}%">` : ''}</div></div></figure>
|
||||
@@ -208,7 +212,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
${hasTransparency || v!.context ? `<div class="view-controls">${v!.context ? `<div role="group" aria-label="Component view"><button id="isolated" aria-pressed="${!useContext}">${isRaster?'Asset only':'Component only'}</button><button id="context" aria-pressed="${useContext}">In context</button></div>` : ''}${hasTransparency?`<div class="background-options" role="group" aria-label="Asset preview background"><button id="background-checker" class="swatch-button" aria-label="Checkerboard background" title="Checkerboard background" aria-pressed="${backdrop==='checker'}" ${useContext?'disabled':''}><span class="background-swatch checker"></span></button><button id="background-page" class="swatch-button" aria-label="${vp.comp.background?'Page color':'Neutral'} background" title="${vp.comp.background?'Page color':'Neutral'} background" aria-pressed="${backdrop==='page'}" ${useContext?'disabled':''}><span class="background-swatch page-swatch"></span></button></div>`:''}</div>` : ''}
|
||||
</div></div>${!assembled?`<div class="component-details"><div class="material">${icon(presentation!.code ? 'code' : 'image')}<strong>${esc(materialLabel)}</strong><span>${v?.material ? `${v.material.width} × ${v.material.height} px` : ''}</span></div>${vp.stage==='components'&&presentation?.captured&&!v?.preview.isolation?'<p class="layering">Legacy region capture · may include overlapping components.</p>':''}${v?.context?.layering&&(isRaster||useContext)?`<p class="layering">${esc(v.context.layering)}</p>`:''}
|
||||
<p class="component-note">${esc(v!.note)}</p>
|
||||
</div>`:''}</div><div class="review-form">${notice}${viewingPrevious?'<p class="previous-notice">Viewing the previous round. Return to Current to make a decision.</p>':''}${submitted?`<div class="record-verdict"><strong>${d?.action==='approve'?'Approved':d?.action==='revise'?'Changes requested':'Not reviewed'}</strong><span>Submitted in round ${packet.round} · read-only</span></div>`:`<div class="decisions" role="group" aria-label="Decision for ${esc(c.name)}">${!assembled?`<div class="decision-title"><strong>Your review <span>Round ${packet.round}</span></strong>${viewingPrevious?'<p>Return to Current to review this round.</p>':''}</div>`:''}<button id="approve" class="decision-approve ${d?.action === 'approve' ? 'approved' : ''}" aria-pressed="${d?.action === 'approve'}">${assembled?(sending?'Sending…':'Approve & continue'):'Looks good'}</button><button id="revise" class="decision-revise ${d?.action === 'revise' ? 'revise' : ''}" aria-pressed="${d?.action === 'revise'}">Needs work</button>${d && !assembled ? `<button id="clear" class="quiet icon-button" aria-label="Clear decision" title="Clear decision">${icon('undo')}</button>` : ''}</div>`}
|
||||
</div>`:''}</div><div class="review-form">${notice}${viewingPrevious?'<p class="previous-notice">Viewing the previous round. Return to Current to make a decision.</p>':''}${submitted?`<div class="record-verdict"><strong>${d?.action==='approve'?'Approved':d?.action==='revise'?'Changes requested':'Not reviewed'}</strong><span>Submitted in round ${packet.round} · read-only</span></div>`:`<div class="decisions" role="group" aria-label="Decision for ${esc(c.name)}">${!assembled?`<div class="decision-title"><strong>Your review <span>Round ${packet.round}</span></strong>${viewingPrevious?'<p>Return to Current to review this round.</p>':''}</div>`:''}<button id="approve" class="decision-approve ${d?.action === 'approve' ? 'approved' : ''}" aria-pressed="${d?.action === 'approve'}">${assembled?(sending?'Sending…':'Approve & continue'):targets.length>1?`Approve ${targets.length} instances`:'Looks good'}</button><button id="revise" class="decision-revise ${d?.action === 'revise' ? 'revise' : ''}" aria-pressed="${d?.action === 'revise'}">${targets.length>1?`Revise ${targets.length} instances`:'Needs work'}</button>${d && !assembled ? `<button id="clear" class="quiet icon-button" aria-label="Clear decision" title="Clear decision">${icon('undo')}</button>` : ''}</div>`}
|
||||
${edit ? `<form id="feedback-form"><div class="feedback-fields"><label class="feedback-field">What needs to change?<textarea id="feedback" aria-describedby="feedback-hint">${esc(edit.feedback)}</textarea></label><p id="feedback-hint" class="feedback-hint">Optional — leave blank for the agent to diagnose.</p>${!assembled?`<label class="check"><input id="split" type="checkbox" ${edit.split ? 'checked' : ''}> Split into separately reviewable components</label>`:''}</div><div class="feedback-actions"><button id="cancel-feedback" type="button" class="quiet">Cancel</button><button id="save-feedback" type="submit" class="primary">${assembled?'Send feedback':isLast?'Save & finish review':'Save & next'} <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6"/></svg></button><span class="shortcut-hint">${shortcutLabel}</span></div></form>` : d?.action==='revise' ? `<p class="saved-feedback">${esc(d.feedback || 'No note — agent will diagnose.')}</p>` : ''}
|
||||
${assembled?`<p class="page-review-status" role="status">${esc(error || (submitted?'Your decision is saved.':sending?'Sending…':edit?'':'Approval confirms the composition and that nothing is missing.'))}</p>`:''}</div>` : missing ? `<p>This piece will be added to the unresolved inventory.</p><label class="feedback-field">Name<input id="missing-name" value="${esc(missing.name)}"></label><label class="feedback-field">What is missing?<textarea id="missing-feedback">${esc(missing.feedback)}</textarea></label><div class="coordinates">${(['x','y','w','h'] as const).map(k=>`<label>${{x:'Left',y:'Top',w:'Width',h:'Height'}[k]} %<input type="number" data-coordinate="${k}" value="${Math.round(missing.box[k]*1000)/10}" min="0" max="100" step="0.1"></label>`).join('')}</div><button id="remove-missing">Remove this mark</button></div>` : '<p>No components supplied.</p></div>'}
|
||||
</section>
|
||||
@@ -241,7 +245,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
const on = (id:string, action:()=>void) => root.querySelector(`#${id}`)?.addEventListener('click', action);
|
||||
function selectComponent(id:string, enlarge=false) {
|
||||
if(marking)return;
|
||||
finished=false; selected=id; mobilePane='component'; overlay=false; zoom='fit'; outputMode='isolated'; previousRound=false; render();
|
||||
applyGroup=false; finished=false; selected=id; mobilePane='component'; overlay=false; zoom='fit'; outputMode='isolated'; previousRound=false; render();
|
||||
// Use the newly rendered control so map shortcuts share the toolbar's
|
||||
// animation, focus management, reduced-motion and dismissal behavior.
|
||||
if(enlarge)root.querySelector<HTMLButtonElement>('#expand-comparison')?.click();
|
||||
@@ -269,9 +273,10 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
on('undo-decision',()=>{
|
||||
if(!lastDecision||sending||submitted)return;
|
||||
const previous=lastDecision;
|
||||
if(previous.previous)draft.decisions[previous.id]=previous.previous;else delete draft.decisions[previous.id];
|
||||
for(const [id,decision] of Object.entries(previous.previous)){if(decision)draft.decisions[id]=decision;else delete draft.decisions[id];}
|
||||
delete edits[previous.id];selected=previous.id;finished=false;previousRound=false;mobilePane='component';inventoryFilter='all';lastDecision=null;render();focusReview('approve');
|
||||
});
|
||||
root.querySelector('#apply-group')?.addEventListener('change',e=>{applyGroup=(e.target as HTMLInputElement).checked;render();});
|
||||
on('overlay',()=>{overlay=!overlay; render();});
|
||||
on('isolated',()=>{outputMode='isolated';render();});
|
||||
on('context',()=>{outputMode='context';render();});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { appLayout } from './app-layout';
|
||||
export const styles = `
|
||||
.review-peers{display:grid;gap:8px;padding:8px 0 12px;font-size:12px}
|
||||
.review-peers strong span{font-weight:400}.review-peers>div{display:flex;flex-wrap:wrap;gap:4px}
|
||||
.review-peers button{min-width:28px;min-height:28px;padding:3px;border:1px solid var(--line);border-radius:4px;background:var(--paper);color:inherit}
|
||||
.review-peers button[aria-pressed="true"]{background:var(--teal);color:white}
|
||||
.review-peers label{display:flex;align-items:center;gap:6px}
|
||||
|
||||
:host{display:block;color:var(--color-text,#292929);font:14px/1.45 var(--font-sans,Arial,sans-serif);--line:var(--color-border,#ddd);--paper:var(--color-panel,#fff);--muted:var(--color-muted,#666);--teal:var(--color-patina,#28625e);--warn:var(--color-warn,#8a5b30);--selection:#43897f}
|
||||
*{box-sizing:border-box}h1,h2,p,figure{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:4px;background:var(--paper);color:inherit;padding:8px 12px;min-height:36px}button:hover{border-color:var(--teal);color:var(--teal)}button:disabled{cursor:default;opacity:.45}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--teal);outline-offset:3px}button[aria-pressed=true]{box-shadow:inset 0 0 0 1px var(--teal)}input[type=checkbox]{accent-color:var(--teal);width:16px;height:16px;flex-shrink:0}textarea,input:not([type=checkbox]){width:100%;background:var(--paper);color:inherit;border:1px solid #999;border-radius:4px;padding:9px 10px}textarea{resize:vertical;min-height:80px}::selection{background:#c7ddd8}a{color:var(--teal)}
|
||||
.review{max-width:1600px;margin:auto;padding:24px 28px 0}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:16px}h1{font:400 40px/1.05 var(--font-display,Arial,sans-serif);letter-spacing:-.02em}header p{margin-top:8px;font-size:15px}header p span,.medium{color:var(--muted)}.badge{border:1px solid var(--line);padding:5px 10px;font-size:12px;white-space:nowrap}.preview-note{color:var(--muted);font-size:12px;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:24px}
|
||||
|
||||
@@ -21,3 +21,8 @@ test('hover panning reaches both edges, synchronizes midpoint, and ignores fitte
|
||||
expect(hoverPan(110,10,200,150)).toBe(0);
|
||||
expect(hoverPan(110,10,0,600)).toBe(0);
|
||||
});
|
||||
|
||||
test('fit never magnifies tiny crops; deliberate zoom still does', () => {
|
||||
expect(comparisonSize(66,24,600,420,'fit')).toEqual({scale:1,width:66,height:24});
|
||||
expect(comparisonSize(66,24,600,420,2)).toEqual({scale:2,width:132,height:48});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Both panes use comp pixels and the same scale; source-image resolution is separate. */
|
||||
export function comparisonSize(width: number, height: number, availableWidth: number, availableHeight: number, zoom: 'fit' | number) {
|
||||
const scale = zoom === 'fit' ? Math.min(availableWidth / width, availableHeight / height) : zoom;
|
||||
const scale = zoom === 'fit' ? Math.min(1, availableWidth / width, availableHeight / height) : zoom;
|
||||
return { scale, width: width * scale, height: height * scale };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user