diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 3ea377fd9..c8f683c81 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -2733,8 +2733,9 @@ function scanCssTextForOrganicClipPath(styleText) { const kind = m[1].toLowerCase(); const body = m[2]; if (kind === 'path') { - // curves (C, S, Q, T, A) drawing a contour, not a rectilinear M/L/Z outline - const curves = (body.match(/[CSQTA]/g) || []).length; + // curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a + // rectilinear M/L/Z outline; letters in path data are only commands + const curves = (body.match(/[CSQTA]/gi) || []).length; if (curves < 3) continue; findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined }); continue; @@ -2780,11 +2781,21 @@ function scanCssTextForBuriedRaster(styleText) { const firstUrl = value.search(/url\(/i); const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]); let opaqueWash = false; + // an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8 + const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; }; for (const g of gradients) { - const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+))?\s*\)|hsla?\([^)]*?(?:,\s*([\d.]+%?))?\s*\)/gi)].map((a) => a[1] ?? a[2]); - const hexOrNamed = /#[0-9a-f]{3,8}\b|\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '')); - if (!alphas.length && hexOrNamed) { opaqueWash = true; break; } - if (alphas.length && alphas.every((a) => a == null || parseFloat(a) >= 0.9)) { opaqueWash = true; break; } + const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2])); + const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, ''); + // hex stops: 4- and 8-digit forms carry their own alpha + for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) { + const hex = h[1]; + if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255); + else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255); + else alphas.push(1); + } + const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped); + if (named) alphas.push(1); + if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; } } if (!opaqueWash) continue; findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined }); @@ -4442,6 +4453,16 @@ function checkQuality(opts) { // opacity never reaches the screen: the produced material ships as a // compliance token. The CSS-text scan catches the stylesheet form; this // catches computed opacity on the element itself (both engines). + // Skip browser extension injected elements BEFORE any finding is pushed + // (a low-opacity raster those hosts inject used to be recorded and then + // returned by this very skip). Read the id via getAttribute whenever + // `el.id` is not a string: on a
(and other [LegacyOverrideBuiltIns] + // hosts) a named control like shadows the builtin `id` + // getter and returns the control element, whose `.startsWith` is undefined + // and throws (issue #407 — every Shopify product form ships an + // ). + const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || ''); + if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings; { const op = parseFloat(style.opacity); if (Number.isFinite(op) && op < 0.15 && op >= 0) { @@ -4452,14 +4473,6 @@ function checkQuality(opts) { } } } - // Skip browser extension injected elements. Read the id via getAttribute - // whenever `el.id` is not a string: on a (and other - // [LegacyOverrideBuiltIns] hosts) a named control like - // shadows the builtin `id` getter and returns the control element, whose - // `.startsWith` is undefined and throws (issue #407 — every Shopify product - // form ships an ). - const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || ''); - if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings; // --- Line length too long --- (browser-only: needs rect.width) if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) { diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 132d6b921..f15957b92 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -1473,8 +1473,9 @@ function scanCssTextForOrganicClipPath(styleText) { const kind = m[1].toLowerCase(); const body = m[2]; if (kind === 'path') { - // curves (C, S, Q, T, A) drawing a contour, not a rectilinear M/L/Z outline - const curves = (body.match(/[CSQTA]/g) || []).length; + // curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a + // rectilinear M/L/Z outline; letters in path data are only commands + const curves = (body.match(/[CSQTA]/gi) || []).length; if (curves < 3) continue; findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined }); continue; @@ -1520,11 +1521,21 @@ function scanCssTextForBuriedRaster(styleText) { const firstUrl = value.search(/url\(/i); const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]); let opaqueWash = false; + // an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8 + const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; }; for (const g of gradients) { - const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+))?\s*\)|hsla?\([^)]*?(?:,\s*([\d.]+%?))?\s*\)/gi)].map((a) => a[1] ?? a[2]); - const hexOrNamed = /#[0-9a-f]{3,8}\b|\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '')); - if (!alphas.length && hexOrNamed) { opaqueWash = true; break; } - if (alphas.length && alphas.every((a) => a == null || parseFloat(a) >= 0.9)) { opaqueWash = true; break; } + const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2])); + const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, ''); + // hex stops: 4- and 8-digit forms carry their own alpha + for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) { + const hex = h[1]; + if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255); + else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255); + else alphas.push(1); + } + const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped); + if (named) alphas.push(1); + if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; } } if (!opaqueWash) continue; findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined }); @@ -3182,6 +3193,16 @@ function checkQuality(opts) { // opacity never reaches the screen: the produced material ships as a // compliance token. The CSS-text scan catches the stylesheet form; this // catches computed opacity on the element itself (both engines). + // Skip browser extension injected elements BEFORE any finding is pushed + // (a low-opacity raster those hosts inject used to be recorded and then + // returned by this very skip). Read the id via getAttribute whenever + // `el.id` is not a string: on a (and other [LegacyOverrideBuiltIns] + // hosts) a named control like shadows the builtin `id` + // getter and returns the control element, whose `.startsWith` is undefined + // and throws (issue #407 — every Shopify product form ships an + // ). + const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || ''); + if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings; { const op = parseFloat(style.opacity); if (Number.isFinite(op) && op < 0.15 && op >= 0) { @@ -3192,14 +3213,6 @@ function checkQuality(opts) { } } } - // Skip browser extension injected elements. Read the id via getAttribute - // whenever `el.id` is not a string: on a (and other - // [LegacyOverrideBuiltIns] hosts) a named control like - // shadows the builtin `id` getter and returns the control element, whose - // `.startsWith` is undefined and throws (issue #407 — every Shopify product - // form ships an ). - const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || ''); - if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings; // --- Line length too long --- (browser-only: needs rect.width) if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) { diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index 2754d052b..93cc79efb 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -299,7 +299,12 @@ export function gatePlates(state, { specPath = SPEC_PATH } = {}) { // upscaled past the size floor: the comp's grain, its neighbours' // edges, and its resolution ship as the artwork. Crops are never // plates; the crop is the reference the plate is generated from. - if (!isTexture) { + // A fake-mode plate (offline pipelines, eval fixtures) IS the crop by + // design and says so in its tEXt; the identity refusal is for models + // shipping the comp's pixels as artwork, not for the deterministic + // stand-in. + const isFake = img.text && img.text['impeccable:fake'] === '1'; + if (!isTexture && !isFake) { const raw = crop(comp, r.px.x, r.px.y, r.px.w, r.px.h); const same = structureScore(raw, resize(img, raw.width, raw.height)); if (same >= 0.95) reasons.push(`plate ${file} is the comp crop of region ${r.id} (structure ${(same * 100).toFixed(0)}% against the raw region, a resample of the same pixels): a crop of the comp is never a plate; generate the plate from the crop as reference (generate-image.mjs --plate ${r.id})`); diff --git a/skill/scripts/generate-image.mjs b/skill/scripts/generate-image.mjs index 0b4f633dd..1bf97ecaf 100644 --- a/skill/scripts/generate-image.mjs +++ b/skill/scripts/generate-image.mjs @@ -248,7 +248,7 @@ if (plateId) { plateCtx = { spec, specPath, region, ref, refPath, out, size, prompt, comp, encodePng, resize, chroma: wantsChroma ? chromaColor : null }; if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) { const up = resize(ref, ref.width * 2, ref.height * 2); - fs.writeFileSync(out, encodePng(up, { text: { 'impeccable:prompt': prompt } })); + fs.writeFileSync(out, encodePng(up, { text: { 'impeccable:prompt': prompt, 'impeccable:fake': '1' } })); fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'fake', plate: region.id, refs: [refPath] }, null, 2)); console.log(`PLATE: ${out} (${up.width}x${up.height}, fake 2x crop of region ${region.id}, $0.00, no API call)`); process.exit(0); @@ -296,7 +296,8 @@ async function keyChroma(file, keyHex) { const m = (r + b) / 2; img.data[i + 1] = Math.round(g * a + m * (1 - a)); } } - fs.writeFileSync(file, encodePng(img)); + // keep the tEXt chunks (the embedded prompt written before keying) + fs.writeFileSync(file, encodePng(img, { text: img.text && Object.keys(img.text).length ? img.text : null })); return keyed / (img.data.length / 4); }