mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-22 02:56:52 +03:00
Fix component preview fonts and clarify reference scope
Reject unavailable primary font families before publishing captures and record font evidence. Outline separately reviewed regions in single and grouped comparisons without masking the approved comp. Add browser and scope regressions. AI assistance: implemented and validated with OpenAI Codex.
This commit is contained in:
@@ -132,6 +132,7 @@ fn render_page(
|
||||
return {html:document.documentElement.outerHTML,svg:document.querySelectorAll('svg').length,images:document.images.length,controls:document.querySelectorAll('button,input,select,textarea,a[href]').length};
|
||||
})()"#.replace("ASSEMBLED", if assembled { "true" } else { "false" });
|
||||
let dom=page.evaluate_value_in_world(&world,&inspect).map_err(|e|e.message)?;
|
||||
let fonts = page.evaluate_value_in_world(&world, include_str!("component_fonts.js")).map_err(|e| e.message)?;
|
||||
let isolated = if let Some(targets) = isolation {
|
||||
page.set_transparent_background().map_err(|e| e.message)?;
|
||||
let script = format!("({})({})", include_str!("component_isolation.js"), targets);
|
||||
@@ -179,6 +180,7 @@ fn render_page(
|
||||
proof["isolation"] = isolated;
|
||||
proof["capturedDomSha256"] = json!(hash(captured_dom.as_str().unwrap().as_bytes()));
|
||||
}
|
||||
proof["fonts"] = fonts;
|
||||
if assembled {
|
||||
proof["kind"] = json!("assembled-page");
|
||||
proof["scriptPolicy"] = json!("pinned-local-and-inline; network-api-and-workers-disabled");
|
||||
@@ -321,6 +323,23 @@ mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
#[ignore = "requires Chromium"]
|
||||
fn missing_primary_font_is_rejected_and_pinned_font_is_captured() {
|
||||
let png = impeccable_comp::png_io::encode_png(&impeccable_comp::raster::create_image(300,100,[255;4]),&[]).unwrap();
|
||||
let html = "<style>body{margin:0}#piece{font:20px 'ReviewFixtureFont',sans-serif}</style><div id='piece'>Hotel review</div>";
|
||||
let mut inputs = BTreeMap::from([("comp.png".into(),png),("index.html".into(),html.as_bytes().to_vec())]);
|
||||
let packet = json!({"schemaVersion":2,"stage":"components","comp":{"url":"/files/comp.png","width":300,"height":100},"components":[{"id":"piece","box":{"x":0,"y":0,"w":1,"h":1},"preview":{"kind":"page","url":"/files/index.html","selector":"#piece"},"dependencies":[]}]});
|
||||
let error = NativeComponentCapturer.capture(&mut packet.clone(),&inputs).err().unwrap();
|
||||
assert!(error.contains("Primary fonts unavailable: ReviewFixtureFont"), "{error}");
|
||||
inputs.insert("index.html".into(),format!("<style>@font-face{{font-family:ReviewFixtureFont;src:url(font.ttf)}}</style>{html}").into_bytes());
|
||||
inputs.insert("font.ttf".into(),include_bytes!("../../../ui/component-review/fonts/albertsans.ttf").to_vec());
|
||||
let mut local = packet.clone();local["components"][0]["dependencies"]=json!(["font.ttf"]);
|
||||
let capture=NativeComponentCapturer.capture(&mut local,&inputs).unwrap();
|
||||
assert_eq!(capture.evidence["components"][0]["views"]["preview"]["fonts"]["primaryFamilies"],json!(["ReviewFixtureFont"]));
|
||||
inputs.insert("index.html".into(),html.replace("'ReviewFixtureFont',sans-serif","sans-serif").into_bytes());
|
||||
NativeComponentCapturer.capture(&mut packet.clone(),&inputs).unwrap();
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires Chromium"]
|
||||
fn shared_document_reports_all_missing_images_before_decode_and_names_corrupt_images() {
|
||||
let image = impeccable_comp::raster::create_image(40,40,[255,255,255,255]);
|
||||
let png = impeccable_comp::png_io::encode_png(&image,&[]).unwrap();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// document.fonts.ready/check alone do not detect a removed @font-face: the
|
||||
// browser considers a system fallback successful. Probe each visible text
|
||||
// run's primary family against distinct generic fallbacks without changing DOM.
|
||||
(() => {
|
||||
const generic = new Set(['serif','sans-serif','monospace','cursive','fantasy','system-ui','ui-serif','ui-sans-serif','ui-monospace','ui-rounded','math','fangsong']);
|
||||
const families = new Set();
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
for (let node; (node = walker.nextNode());) {
|
||||
if (!node.textContent.trim()) continue;
|
||||
const element = node.parentElement;
|
||||
if (!element || element.closest('script,style,noscript')) continue;
|
||||
const style = getComputedStyle(element);
|
||||
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
|
||||
const range = document.createRange(); range.selectNodeContents(node);
|
||||
if (![...range.getClientRects()].some(r => r.width > 0 && r.height > 0)) continue;
|
||||
const first = (style.fontFamily.match(/"[^"]*"|'[^']*'|[^,]+/g) || [])[0]?.trim();
|
||||
const family = first?.replace(/^(['"])(.*)\1$/, '$2');
|
||||
if (family && !generic.has(family.toLowerCase())) families.add(family);
|
||||
}
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
const sample = 'mmmmmmmmWWWWiiii0123456789';
|
||||
const width = font => { ctx.font = `72px ${font}`; return ctx.measureText(sample).width; };
|
||||
const missing = [...families].filter(family => ['monospace','serif','sans-serif'].every(base =>
|
||||
Math.abs(width(`${JSON.stringify(family)}, ${base}`) - width(base)) < .001));
|
||||
if (missing.length) throw Error(`Primary fonts unavailable: ${missing.join(', ')}. Preserve the intended typography: include local font files and @font-face declarations in the preview dependencies. Removing font imports produces a fallback preview, not a faithful component capture.`);
|
||||
return {primaryFamilies: [...families].sort(), check: 'visible-primary-family-availability-v1'};
|
||||
})()
|
||||
File diff suppressed because one or more lines are too long
@@ -50,6 +50,8 @@ The runtime also captures an unmodified **In context** view from that same docum
|
||||
|
||||
Component capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it.
|
||||
|
||||
Keep the implementation's intended fonts in the review document. Vendor external fonts locally and declare them as dependencies; removing their imports changes the component being reviewed. Capture rejects unavailable primary font families rather than presenting a silent fallback.
|
||||
|
||||
## Present and wait
|
||||
|
||||
If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval.
|
||||
|
||||
@@ -126,3 +126,13 @@ test('scope distinguishes other mapped pieces from explicit capture exclusions',
|
||||
expect(reviewScope(p,child).related).toEqual([]);
|
||||
expect(reviewScope(p,card).description).toBe('Card outline');
|
||||
});
|
||||
|
||||
test('reference scope includes crossing foreground pieces but ignores adjacent pixel seams',()=>{
|
||||
const p=structuredClone(packet),photo=p.components[0];
|
||||
photo.box={x:0,y:0,w:.4,h:1};
|
||||
const other=p.components[1];other.box={x:.2,y:.2,w:.3,h:.1};
|
||||
p.components.push({...other,id:'neighbor',box:{x:.4,y:0,w:.4,h:1}});
|
||||
p.components.push({...other,id:'seam',box:{x:.39999,y:.3,w:.2,h:.1}});
|
||||
expect(reviewScope(p,photo).related.map(c=>c.id)).toEqual(['control']);
|
||||
expect(reviewScope(p,photo).excluded).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -143,11 +143,12 @@ export function reviewScope(packet: ReviewPacket, component: Component) {
|
||||
if (other.id === component.id) return false;
|
||||
if (excluded.has(other.id)) return true;
|
||||
const o = other.box;
|
||||
const overlap = Math.max(0, Math.min(b.x+b.w,o.x+o.w)-Math.max(b.x,o.x)) *
|
||||
Math.max(0, Math.min(b.y+b.h,o.y+o.h)-Math.max(b.y,o.y));
|
||||
// Strictly smaller, substantially contained regions; omit backgrounds and
|
||||
// neighboring cards. This never changes the review or any reference pixels.
|
||||
return o.w*o.h < b.w*b.h && overlap / (o.w*o.h) >= .98;
|
||||
const overlapWidth = Math.min(b.x+b.w,o.x+o.w)-Math.max(b.x,o.x);
|
||||
const overlapHeight = Math.min(b.y+b.h,o.y+o.h)-Math.max(b.y,o.y);
|
||||
// Smaller mapped pieces may cross a photograph's edge (headings and route
|
||||
// lines often do). Include their visible intersection, ignoring subpixel
|
||||
// boundary noise. This describes scope only; no pixels or decisions change.
|
||||
return o.w*o.h < b.w*b.h && overlapWidth*packet.comp.width > 1 && overlapHeight*packet.comp.height > 1;
|
||||
});
|
||||
return {description:component.note.trim(), related, excluded:related.filter(c=>excluded.has(c.id))};
|
||||
}
|
||||
|
||||
@@ -55,9 +55,15 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
const scope = reviewScope(p,item);
|
||||
if (!scope.related.length) return '';
|
||||
const names = scope.related.map(other=>esc(other.name)).join(' · ');
|
||||
return `<div class="review-scope" aria-label="Review scope"><p><strong>Reviewing</strong> ${esc(scope.description || item.name)}</p><p class="separate-reviews"><strong>Separate review items</strong> ${names}</p>${scope.excluded.length?`<p class="scope-excluded">Hidden in this preview: ${scope.excluded.map(other=>esc(other.name)).join(' · ')}</p>`:''}</div>`;
|
||||
return `<div class="review-scope" aria-label="Review scope"><p><strong>Reviewing</strong> ${esc(scope.description || item.name)}</p><p class="separate-reviews"><strong>Outlined · reviewed separately</strong> ${names}</p>${scope.excluded.length?`<p class="scope-excluded">Hidden in this preview: ${scope.excluded.map(other=>esc(other.name)).join(' · ')}</p>`:''}</div>`;
|
||||
};
|
||||
const boxStyle = (b: Box) => `left:${pct(b.x)};top:${pct(b.y)};width:${pct(b.w)};height:${pct(b.h)}`;
|
||||
const referenceLayers = (p: ReviewPacket, item: Component) => p.stage === 'hero' ? '' : reviewScope(p,item).related.map(other => {
|
||||
const b=item.box,o=other.box;
|
||||
const x=Math.max(b.x,o.x),y=Math.max(b.y,o.y);
|
||||
const relative={x:(x-b.x)/b.w,y:(y-b.y)/b.h,w:Math.max(0,Math.min(b.x+b.w,o.x+o.w)-x)/b.w,h:Math.max(0,Math.min(b.y+b.h,o.y+o.h)-y)/b.h};
|
||||
return `<span class="reference-layer" style="${boxStyle(relative)}" title="Reviewed separately: ${esc(other.name)}" aria-label="Reviewed separately: ${esc(other.name)}"><span>${esc(other.name)}</span></span>`;
|
||||
}).join('');
|
||||
function focusReview(id: string) {
|
||||
root.getElementById(id)?.focus({preventScroll:true});
|
||||
}
|
||||
@@ -220,9 +226,9 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
|
||||
</div>`:''}
|
||||
|
||||
<div class="comparison-slot"><div class="comparison-panel ${groupOverview?'group-overview':''}"><h2 class="expanded-title">${esc(groupOverview?unit!.label:v!.name)}</h2>${peers.length>1?`<div class="review-peers"><strong>${peers.length} instances</strong>${!groupOverview?'<button id="all-instances" class="quiet">All instances</button>':'<span class="group-hint">Select to inspect</span>'}</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>
|
||||
${groupOverview?`<div class="instance-grid" aria-label="All instances of ${esc(unit!.label)}"><div class="instance-grid-labels"><span>Full comp crop</span><span>Component preview</span></div>${unit!.members.map((m,n)=>{const st=stateFor(m);return `<button class="instance-row ${st.kind}" data-instance="${esc(m.id)}" aria-label="Inspect instance ${n+1}: ${esc(m.name)} — ${esc(st.label)}"><span class="instance-caption"><strong>${esc(m.name)}</strong><span>${esc(st.label)}</span></span><span class="instance-pair"><span class="instance-reference" style="width:min(100%,${m.box.w*packet.comp.width}px,${180*m.box.w*packet.comp.width/(m.box.h*packet.comp.height)}px);aspect-ratio:${m.box.w*packet.comp.width}/${m.box.h*packet.comp.height}"><img src="${url(packet.comp.url)}" alt="Comp: ${esc(m.name)}" loading="lazy" style="width:${100/m.box.w}%;left:${-100*m.box.x/m.box.w}%;top:${-100*m.box.y/m.box.h}%"></span><span class="instance-produced" style="width:min(100%,${m.box.w*packet.comp.width}px,${180*m.box.w*packet.comp.width/(m.box.h*packet.comp.height)}px);aspect-ratio:${m.box.w*packet.comp.width}/${m.box.h*packet.comp.height}">${m.preview.kind==='image'?`<img src="${url(m.preview.url)}" alt="Produced: ${esc(m.name)}" loading="lazy">`:m.thumbnail?`<img src="${url(m.thumbnail.url)}" alt="Preview: ${esc(m.name)}" loading="lazy">`:'Open live component'}</span></span>${scopeMarkup(packet,m)}</button>`;}).join('')}</div>`:''}
|
||||
${groupOverview?`<div class="instance-grid" aria-label="All instances of ${esc(unit!.label)}"><div class="instance-grid-labels"><span>Full comp crop</span><span>Component preview</span></div>${unit!.members.map((m,n)=>{const st=stateFor(m);return `<button class="instance-row ${st.kind}" data-instance="${esc(m.id)}" aria-label="Inspect instance ${n+1}: ${esc(m.name)} — ${esc(st.label)}"><span class="instance-caption"><strong>${esc(m.name)}</strong><span>${esc(st.label)}</span></span><span class="instance-pair"><span class="instance-reference" style="width:min(100%,${m.box.w*packet.comp.width}px,${180*m.box.w*packet.comp.width/(m.box.h*packet.comp.height)}px);aspect-ratio:${m.box.w*packet.comp.width}/${m.box.h*packet.comp.height}"><img src="${url(packet.comp.url)}" alt="Comp: ${esc(m.name)}" loading="lazy" style="width:${100/m.box.w}%;left:${-100*m.box.x/m.box.w}%;top:${-100*m.box.y/m.box.h}%">${referenceLayers(packet,m)}</span><span class="instance-produced" style="width:min(100%,${m.box.w*packet.comp.width}px,${180*m.box.w*packet.comp.width/(m.box.h*packet.comp.height)}px);aspect-ratio:${m.box.w*packet.comp.width}/${m.box.h*packet.comp.height}">${m.preview.kind==='image'?`<img src="${url(m.preview.url)}" alt="Produced: ${esc(m.name)}" loading="lazy">`:m.thumbnail?`<img src="${url(m.thumbnail.url)}" alt="Preview: ${esc(m.name)}" loading="lazy">`:'Open live component'}</span></span>${scopeMarkup(packet,m)}</button>`;}).join('')}</div>`:''}
|
||||
${!groupOverview&&v?scopeMarkup(vp,v):''}<div class="compare">
|
||||
<figure><figcaption>${viewingPrevious ? `Comp · Round ${history!.packet.round}` : assembled ? 'Approved comp' : visibleScope?.related.length ? 'Full comp crop' : '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 ? `Comp · Round ${history!.packet.round}` : assembled ? 'Approved comp' : visibleScope?.related.length ? 'Full comp crop' : '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}%">${referenceLayers(vp,v!)}</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>
|
||||
</div>
|
||||
${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>` : ''}
|
||||
|
||||
@@ -134,4 +134,6 @@ export const styles = `
|
||||
.review-scope .separate-reviews,.review-scope .scope-excluded{font-size:11px;color:var(--muted)}
|
||||
.instance-row .review-scope{margin:12px 0 0}
|
||||
#comparison-dialog .review-scope{flex:none}
|
||||
|
||||
.reference-layer{position:absolute;box-sizing:border-box;border:1px dashed #fff;outline:1px solid #173e43;pointer-events:none;z-index:2}.reference-layer>span{position:absolute;top:0;left:0;max-width:100%;padding:1px 4px;background:#173e43;color:white;font:10px/1.4 var(--font-sans,sans-serif);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0;transition:opacity .12s}.instance-row:hover .reference-layer>span,.instance-row:focus-visible .reference-layer>span,.pan-viewport:hover .reference-layer>span,.pan-viewport:focus .reference-layer>span{opacity:1}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user