Plate pipeline, asset producer rewrite, and two detector rules for CSS standing in for material

generate-image.mjs --plate produces one raster region of the measured spec
from the comp crop, scores it against the crop, and refuses under --min.
The asset producer's job becomes producing the spec's plates. Detector
gains organic-clip-path (many-vertex polygon / curved path() clips) and
buried-raster (raster under a near-opaque wash or at near-zero opacity),
wired into both engines with fixtures.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-28 06:09:46 +05:00
committed by Abdul Wahab
co-authored by Claude
parent b0fc2e8801
commit 5856161014
12 changed files with 547 additions and 79 deletions
+157 -3
View File
@@ -232,6 +232,24 @@ const ANTIPATTERNS = [
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
{
id: 'organic-clip-path',
category: 'quality',
name: 'Organic contour drawn as clip-path',
description:
'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
skillSection: 'Imagery',
skillGuideline: 'geometric masks standing in for organic contours',
},
{
id: 'buried-raster',
category: 'quality',
name: 'Raster buried under a wash or opacity',
description:
'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
skillSection: 'Imagery',
skillGuideline: 'a produced material must survive to the screen',
},
{
id: 'dark-glow',
category: 'slop',
@@ -1924,8 +1942,13 @@ function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
// A match inside an inline style fragment (`style="…"` appended to the
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
@@ -2691,6 +2714,84 @@ function scanHtmlForShapeAssembledIllustration(html) {
return findings;
}
// --- Organic clip-path polygons ----------------------------------------------
// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
// with curves, is CSS approximating an organic contour: a torn edge, a blob,
// a silhouette. The approximation reads as the cheap version of the effect
// (the craft floor's geometric-occlusion-mask ban), and it is the signature
// of a comp's produced material being replaced with code. Geometric clips
// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
// from an alpha matte passes.
const ORGANIC_POLYGON_MIN_VERTICES = 10;
function scanCssTextForOrganicClipPath(styleText) {
const findings = [];
const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
let m;
while ((m = re.exec(styleText)) !== null) {
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;
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;
}
const points = body.split(',').map((p) => p.trim()).filter(Boolean);
if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
// Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
// contour has arbitrary values.
let offGrid = 0;
for (const p of points) {
const nums = p.match(/-?[\d.]+/g) || [];
for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
}
if (offGrid < points.length) continue;
findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
}
return findings;
}
// --- Buried raster ------------------------------------------------------------
// A raster (background-image url or <img>) that never reaches the screen:
// under a near-opaque gradient wash in the same background stack, or on an
// element at near-zero opacity. It is how a produced texture "ships" while
// the page shows flat color, and the finish reviewer cannot see it either.
// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
function scanCssTextForBuriedRaster(styleText) {
const findings = [];
// background stacks: split declarations, look for url() + a gradient whose
// stops all carry alpha >= 0.9 (or opaque hex/named colors)
const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
let m;
while ((m = declRe.exec(styleText)) !== null) {
const value = m[1];
if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
// a blend mode declared in the same rule keeps the raster visible
const ruleStart = styleText.lastIndexOf('{', m.index);
const ruleEnd = styleText.indexOf('}', m.index);
const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
// Layers are painted first-on-top: only a wash listed BEFORE the url()
// covers it. An image on top of a gradient is not buried.
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;
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; }
}
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 });
}
return findings;
}
// Scoped scan corpora for the page-level pattern checks. CSS-property
// regexes run over the whole source string fire on documentation ABOUT
// css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
@@ -2863,6 +2964,10 @@ function checkHtmlPatterns(html, corpora) {
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
// Organic clip-path contours and rasters buried under washes or opacity
findings.push(...scanCssTextForOrganicClipPath(styleText));
findings.push(...scanCssTextForBuriedRaster(styleText));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(styleText, html));
@@ -4333,6 +4438,20 @@ function isNonRenderedText(el, tag, style) {
function checkQuality(opts) {
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
const findings = [];
// A raster (<img>, or an element with a background url) at near-zero
// 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).
{
const op = parseFloat(style.opacity);
if (Number.isFinite(op) && op < 0.15 && op >= 0) {
const bg = String(style.backgroundImage || '');
if (tag === 'img' || /url\(/i.test(bg)) {
const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
}
}
}
// Skip browser extension injected elements. Read the id via getAttribute
// whenever `el.id` is not a string: on a <form> (and other
// [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
@@ -6404,6 +6523,36 @@ function checkTextOcclusionDOM() {
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// The part of an element that is actually painted, after every scrolling or
// clipping ancestor has had its say.
//
// getBoundingClientRect reports where a box would be if nothing cut it off,
// so a paragraph half scrolled out of a panel still reports its full height,
// and the half that is clipped away lands wherever the page continues below
// the panel. The elementFromPoint probe then samples coordinates the text is
// not painted at, finds whatever genuinely is painted there, and reports the
// text as buried under it. Any sticky footer or toolbar beneath a scroll
// region produces this, and it is the shape most likely to be waved off as
// noise, which costs the rule its credibility on the findings that are real.
//
// Border box rather than padding box on purpose: it errs toward probing, and
// giving up a scrollbar gutter's width would drop true findings at the right
// edge of a scroller.
const paintedRect = (el, rect) => {
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
let cs; try { cs = getComputedStyle(cur); } catch { continue; }
const clipsX = String(cs.overflowX || 'visible') !== 'visible';
const clipsY = String(cs.overflowY || 'visible') !== 'visible';
if (!clipsX && !clipsY) continue;
let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
if (right - left < 1 || bottom - top < 1) return null;
}
return { left, top, right, bottom, width: right - left, height: bottom - top };
};
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
const textEls = [];
@@ -6416,8 +6565,13 @@ function checkTextOcclusionDOM() {
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
let full; try { full = el.getBoundingClientRect(); } catch { continue; }
if (full.width < 6 || full.height < 6) continue;
// Probe only where the text is on screen. A run clipped down to a sliver is
// dropped rather than sampled: a few pixels of visible text cannot support
// a coverage fraction worth reporting either way.
const rect = paintedRect(el, full);
if (!rect || rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
if (rect.bottom <= 0 || rect.top >= vh) continue;
textEls.push({ el, rect, text, inSvg });
+18
View File
@@ -121,6 +121,24 @@ const ANTIPATTERNS = [
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
{
id: 'organic-clip-path',
category: 'quality',
name: 'Organic contour drawn as clip-path',
description:
'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
skillSection: 'Imagery',
skillGuideline: 'geometric masks standing in for organic contours',
},
{
id: 'buried-raster',
category: 'quality',
name: 'Raster buried under a wash or opacity',
description:
'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
skillSection: 'Imagery',
skillGuideline: 'a produced material must survive to the screen',
},
{
id: 'dark-glow',
category: 'slop',
+141 -3
View File
@@ -682,8 +682,13 @@ function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
// A match inside an inline style fragment (`style="…"` appended to the
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
@@ -1449,6 +1454,84 @@ function scanHtmlForShapeAssembledIllustration(html) {
return findings;
}
// --- Organic clip-path polygons ----------------------------------------------
// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
// with curves, is CSS approximating an organic contour: a torn edge, a blob,
// a silhouette. The approximation reads as the cheap version of the effect
// (the craft floor's geometric-occlusion-mask ban), and it is the signature
// of a comp's produced material being replaced with code. Geometric clips
// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
// from an alpha matte passes.
const ORGANIC_POLYGON_MIN_VERTICES = 10;
function scanCssTextForOrganicClipPath(styleText) {
const findings = [];
const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
let m;
while ((m = re.exec(styleText)) !== null) {
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;
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;
}
const points = body.split(',').map((p) => p.trim()).filter(Boolean);
if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
// Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
// contour has arbitrary values.
let offGrid = 0;
for (const p of points) {
const nums = p.match(/-?[\d.]+/g) || [];
for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
}
if (offGrid < points.length) continue;
findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
}
return findings;
}
// --- Buried raster ------------------------------------------------------------
// A raster (background-image url or <img>) that never reaches the screen:
// under a near-opaque gradient wash in the same background stack, or on an
// element at near-zero opacity. It is how a produced texture "ships" while
// the page shows flat color, and the finish reviewer cannot see it either.
// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
function scanCssTextForBuriedRaster(styleText) {
const findings = [];
// background stacks: split declarations, look for url() + a gradient whose
// stops all carry alpha >= 0.9 (or opaque hex/named colors)
const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
let m;
while ((m = declRe.exec(styleText)) !== null) {
const value = m[1];
if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
// a blend mode declared in the same rule keeps the raster visible
const ruleStart = styleText.lastIndexOf('{', m.index);
const ruleEnd = styleText.indexOf('}', m.index);
const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
// Layers are painted first-on-top: only a wash listed BEFORE the url()
// covers it. An image on top of a gradient is not buried.
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;
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; }
}
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 });
}
return findings;
}
// Scoped scan corpora for the page-level pattern checks. CSS-property
// regexes run over the whole source string fire on documentation ABOUT
// css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
@@ -1621,6 +1704,10 @@ function checkHtmlPatterns(html, corpora) {
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
// Organic clip-path contours and rasters buried under washes or opacity
findings.push(...scanCssTextForOrganicClipPath(styleText));
findings.push(...scanCssTextForBuriedRaster(styleText));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(styleText, html));
@@ -3091,6 +3178,20 @@ function isNonRenderedText(el, tag, style) {
function checkQuality(opts) {
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
const findings = [];
// A raster (<img>, or an element with a background url) at near-zero
// 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).
{
const op = parseFloat(style.opacity);
if (Number.isFinite(op) && op < 0.15 && op >= 0) {
const bg = String(style.backgroundImage || '');
if (tag === 'img' || /url\(/i.test(bg)) {
const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
}
}
}
// Skip browser extension injected elements. Read the id via getAttribute
// whenever `el.id` is not a string: on a <form> (and other
// [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
@@ -5162,6 +5263,36 @@ function checkTextOcclusionDOM() {
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// The part of an element that is actually painted, after every scrolling or
// clipping ancestor has had its say.
//
// getBoundingClientRect reports where a box would be if nothing cut it off,
// so a paragraph half scrolled out of a panel still reports its full height,
// and the half that is clipped away lands wherever the page continues below
// the panel. The elementFromPoint probe then samples coordinates the text is
// not painted at, finds whatever genuinely is painted there, and reports the
// text as buried under it. Any sticky footer or toolbar beneath a scroll
// region produces this, and it is the shape most likely to be waved off as
// noise, which costs the rule its credibility on the findings that are real.
//
// Border box rather than padding box on purpose: it errs toward probing, and
// giving up a scrollbar gutter's width would drop true findings at the right
// edge of a scroller.
const paintedRect = (el, rect) => {
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
let cs; try { cs = getComputedStyle(cur); } catch { continue; }
const clipsX = String(cs.overflowX || 'visible') !== 'visible';
const clipsY = String(cs.overflowY || 'visible') !== 'visible';
if (!clipsX && !clipsY) continue;
let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
if (right - left < 1 || bottom - top < 1) return null;
}
return { left, top, right, bottom, width: right - left, height: bottom - top };
};
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
const textEls = [];
@@ -5174,8 +5305,13 @@ function checkTextOcclusionDOM() {
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
let full; try { full = el.getBoundingClientRect(); } catch { continue; }
if (full.width < 6 || full.height < 6) continue;
// Probe only where the text is on screen. A run clipped down to a sliver is
// dropped rather than sampled: a few pixels of visible text cannot support
// a coverage fraction worth reporting either way.
const rect = paintedRect(el, full);
if (!rect || rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
if (rect.bottom <= 0 || rect.top >= vh) continue;
textEls.push({ el, rect, text, inSvg });
@@ -5444,6 +5580,8 @@ export {
cssLengthToPx,
scanCssTextForPulsingDot,
scanHtmlForShapeAssembledIllustration,
scanCssTextForOrganicClipPath,
scanCssTextForBuriedRaster,
buildHtmlPatternCorpora,
checkHtmlPatterns,
readOwnBackgroundColor,
+12 -62
View File
@@ -26,77 +26,27 @@ When the parent hands you a decision card packet instead of an approved mock, th
## Input Contract
Expect:
Expect the measured spec (`.impeccable/build/spec.json`, written by `comp-spec.mjs` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on.
- Approved mock path or screenshot reference.
- Crop paths or a contact sheet with crop ids.
- Output directory.
- Required dimensions, format, transparency needs, and avoid list.
- Notes on what should remain semantic HTML/CSS/SVG instead of raster.
If there is no spec, stop and return one line asking the parent to run `comp-spec.mjs` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first.
If the source mock is attached but has no filesystem path, use it for visual planning; ask for a path only before cropping or writing assets.
## The job
Defaults unless contradicted:
Every region with `medium: raster` in the spec ships as a plate at its `plate` path. A plate is the region regenerated at asset resolution from the comp crop as reference: same subject, same composition, same palette, same lighting and material, with the UI text and page chrome removed, at 1.5x the comp region's pixel size or more. The page draws text, controls, radius, shadow, and layout in code; the plate carries what code cannot draw. Crops from the comp are references, never shipping pixels: a comp is reference grade and a shipped crop is how a beautiful comp becomes a blurry site.
- `.webp` for opaque photos, backgrounds, and textures.
- `.png` for transparent cutouts, seals, tickets, and illustrations.
- Target production size, or at least 2x display size when dimensions are known. Never default to the small size of a full-page mock crop.
- Remove UI text, navigation, buttons, labels, and body copy.
- Keep physical marks only when the parent says they are part of the asset.
- Remove letterboxing, empty padding, baked card corners, borders, shadows, caption bands, and layout background unless the parent says those pixels are intrinsic.
- Keep the final assets directory clean: only files the build will consume. Source crops, reference crops, masks, and contact sheets go in a sibling `_sources`, `sources`, or review folder.
Per region, in the spec's order:
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not; choose defaults and report them.
## Workflow
1. Inventory the full approved mock or every assigned crop.
2. Put each visual role in exactly one bucket:
- `produce`: needs generation, image editing, cleanup, cutout work, or a clean plate before it can ship.
- `direct`: ships after format conversion, compression, or renaming because the parent supplied a real standalone source: a project file, stock, or prior production art. A crop from the approved mock is never `direct`, whatever its apparent size.
- `semantic`: build in HTML/CSS/SVG/canvas, no raster output.
3. Crops from the mock are binding visual references, never shipping pixels: a full-page mock's effective resolution is reference grade, and a shipped crop, however close it looks, is how a beautiful comp becomes a blurry site. Every mock-derived asset goes through `produce` as a clean regeneration.
4. Give the parent an execution order for the `produce` bucket.
5. For produced assets, choose the least inventive strategy: image-to-image clean plate, faithful regeneration from crop reference, transparent cutout, texture/pattern reconstruction, stock/project source, or a semantic HTML/CSS/SVG recommendation when raster is wrong.
6. Use the harness's native image tool by default when generation or editing is needed; otherwise use the skill's generate-image.mjs.
1. `node {{scripts_path}}/comp-spec.mjs --crop <id>` writes the reference crop under `.impeccable/build/crops/`.
2. Produce the plate. With the API fallback: `node {{scripts_path}}/generate-image.mjs --plate <id> --quality high` does the whole step (crop as reference, the spec's plate prompt, output size chosen from the region's aspect, the file written to its plate path, prompt embedded, and the plate scored against the crop). With a harness-native image tool: use the crop as the input image and `node {{scripts_path}}/comp-spec.mjs --plate-prompt <id>` as the prompt, write the result to the plate path, then run `node {{scripts_path}}/embed-prompt.mjs <plate> --prompt "<the exact prompt>"`.
3. Read the score line. `PLATE-SCORE` under 50%, or a `PLATE-WARN`, means the plate does not read as the region: open the plate beside the crop, name what drifted (subject, framing, palette, style), tighten the prompt with that, and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and say why in one line.
4. Transparent cutouts (a figure or object on the page ground): generate on a flat chroma color absent from the subject and key it to alpha before writing the PNG; never ship the keyed background.
<codex>
Codex: the imagegen skill's built-in `image_gen` path is the native tool here; prefer it for generation, editing, and the chroma-key workflow.
Codex: the imagegen skill's built-in `image_gen` path is the native tool here; prefer it for generation and editing, with the crop as the input image.
</codex>
7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset.
8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap.
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node {{scripts_path}}/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt lives inside the image itself. The build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed first, classify it as crop-derived cleanup or clean-plate work.
Use `semantic` for dashboards, charts, controls, screenshots of whole UI sections, data widgets, card chrome, app frames, icon toolbars, logos, wordmarks, and anything the final implementation can render crisply in HTML/CSS/SVG/canvas. Ship a screenshot raster only when the parent explicitly says the screenshot itself is the final asset.
Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it composes with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
## Prompt Pattern
Use this shape for image-to-image work:
```text
Use the provided crop as the approved visual reference.
Recreate the same asset as a clean reusable production image at the target component aspect ratio and at least 2x display resolution.
Preserve silhouette, object/scene perspective, camera angle, palette, lighting, material, texture, and visual role.
Remove baked-in UI copy, navigation, buttons, labels, body text, watermarks, and mock chrome unless explicitly part of the asset.
Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, perspective transforms, caption bands, and layout backgrounds that the implementation should create in code.
Do not add new objects. Do not change the concept. Do not redesign the composition.
```
For transparent cutouts: use true alpha when the tool supports it; otherwise generate on a flat chroma-key color that cannot appear in the subject and post-process that color to alpha before shipping the PNG/WebP. Never ship the keyed background as the final asset.
Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate.
## Output Contract
Return a complete manifest, grouped by `produce`, `direct`, and `semantic`. For each asset include: `id`, `source_crop`, `output_path` when applicable, `strategy`, `prompt_used` when applicable, `dimensions`, `format`, `transparency`, `deviations`, and `qa_status`.
For each semantic row include `id`, `implementation`, `notes`, and `qa_status`. The `implementation` is a concrete build handoff, not a note that no asset was produced: name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities code owns.
`qa_status` is `accepted`, `needs_parent_review`, or `blocked`. `accepted` only after visual comparison passes. `needs_parent_review` for cut-off subjects, unwanted borders or rounded-card chrome, letterboxing, baked semantic text, low-resolution output, perspective that should have been CSS, missing transparency, or drift from the crop. `blocked` when inputs, permissions, image capability, or asset source quality prevent a credible result.
End with `execution_order`, `blockers`, and `assumptions` sections. Keep blockers global and minimal; per-asset rows carry only asset-specific risks or decisions.
Do not modify implementation code. Do not edit the approved mock. Do not produce final page copy. The parent craft agent owns implementation and final mock fidelity.
Return one line per raster region: `<id> <plate path> <WxH> <score>% <accepted|needs_parent_review|blocked> <one-line note or ->`. Then `blockers` (missing spec, missing comp, no image capability, exhausted key) and `assumptions`, each global and minimal. Nothing else: no summary, no praise, no implementation advice. The parent runs `build-phase.mjs advance` to verify the plates against the same spec; your line and its line must agree.
+2 -2
View File
@@ -54,7 +54,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
export const STATE_PATH = path.join(BUILD_DIR, 'state.json');
export const PHASES = ['spec', 'plates', 'hero', 'sections', 'motion', 'responsive', 'review'];
export const HERO_MIN = 0.72;
export const PLATE_MIN = 0.6;
export const PLATE_MIN = 0.5;
export const HERO_REPRO = path.join('.impeccable', 'review', 'hero-repro.png');
function arg(name, fallback = null) {
@@ -120,7 +120,7 @@ export function gatePlates(state, { specPath = SPEC_PATH } = {}) {
let score = null;
if (comp) {
const ref = crop(comp, r.px.x, r.px.y, r.px.w, r.px.h);
const res = compare({ comp: ref, build: img, align: 'stretch', spec: null });
const res = compare({ comp: ref, build: img, align: 'cover', spec: null, kind: r.kind });
score = res.whole;
if (score.overall < PLATE_MIN) reasons.push(`plate ${file} scores ${(score.overall * 100).toFixed(0)}% against the comp region ${r.id} (structure ${(score.structure * 100).toFixed(0)}%, color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); it does not read as the same region. Regenerate with the crop as --ref and the comp-spec plate prompt.`);
}
+13 -3
View File
@@ -55,9 +55,19 @@ export function readPng(file) {
return decodePng(fs.readFileSync(file));
}
/** Scale the build to the comp's width; take the top comp-height rows (align=top) or squash (align=stretch). */
/**
* Scale the build to the comp's width; take the top comp-height rows
* (align=top), squash the whole build onto the comp (align=stretch), or scale
* to cover and center-crop (align=cover, the way `object-fit: cover` will show
* a plate whose aspect differs from its region).
*/
export function alignBuild(comp, build, align = 'top') {
if (align === 'stretch') return resize(build, comp.width, comp.height);
if (align === 'cover') {
const s = Math.max(comp.width / build.width, comp.height / build.height);
const scaled = resize(build, build.width * s, build.height * s);
return crop(scaled, (scaled.width - comp.width) / 2, (scaled.height - comp.height) / 2, comp.width, comp.height);
}
const scaled = build.width === comp.width ? build : resize(build, comp.width, Math.round((build.height / build.width) * comp.width));
if (scaled.height === comp.height) return scaled;
if (scaled.height > comp.height) return crop(scaled, 0, 0, comp.width, comp.height);
@@ -191,9 +201,9 @@ export function renderRegionPair(compCrop, buildCrop, id, score) {
return out;
}
export function compare({ comp, build, spec = null, align = 'top', label = '' }) {
export function compare({ comp, build, spec = null, align = 'top', label = '', kind = null }) {
const aligned = alignBuild(comp, build, align);
const whole = scorePair(comp, aligned);
const whole = scorePair(comp, aligned, kind);
const regions = resolveRegions(comp, spec).map((r) => {
const a = regionCrop(comp, r), b = regionCrop(aligned, r);
const s = scorePair(a, b, r.kind);
+1 -1
View File
@@ -156,7 +156,7 @@ export function platePrompt(spec, region) {
`Preserve silhouette, composition, perspective, palette (${world}), lighting, material, and texture exactly.`,
'Remove every piece of UI text, label, caption, button, and interface chrome that is not part of the artwork itself.',
'Remove letterboxing, borders, card corners, drop shadows, and any layout background that the page will draw in code.',
'Do not add objects. Do not change the concept. Do not restyle. Fill the whole frame; no margins.',
'Do not add objects. Do not change the concept. Do not restyle. The artwork fills the whole frame edge to edge at the same scale as the reference; no margins, no border, no background band.',
region.note ? `Region: ${region.note}.` : '',
].filter(Boolean).join(' ');
}
+76 -5
View File
@@ -15,8 +15,21 @@
* --ref anchors generation on input image(s) via the edits endpoint: pass a
* captured screenshot of a representative existing page when comping a new
* surface for an established world, so the identity comes from the real UI.
*
* node generate-image.mjs --plate <region-id> [--spec .impeccable/build/spec.json] [--quality high]
*
* --plate produces a shipping raster for one raster region of the measured
* comp spec (comp-spec.mjs): it crops the region from the approved comp,
* sends the crop as the reference with the spec's plate prompt (plus any
* --prompt you add), picks the closest supported output size to the region's
* aspect, writes the result to the region's `plate` path, embeds the prompt,
* and scores the plate against the comp crop with comp-diff so a plate that
* does not read as the region is reported (and, with --min, refused) here,
* before it lands on the page. In IMPECCABLE_IMAGE_GEN_FAKE mode the plate is
* the crop itself at 2x, so offline pipelines can walk the plate gate.
*/
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
function arg(name, fallback = null) {
@@ -186,6 +199,63 @@ function parseSize(sizeStr) {
return [Number(m[1]), Number(m[2])];
}
// ---------------------------------------------------------------------------
// Plate mode: one raster region of the measured spec -> a shipping plate.
// ---------------------------------------------------------------------------
const plateId = arg('plate');
let plateCtx = null;
if (plateId) {
const { loadSpec, platePrompt, SPEC_PATH } = await import('./comp-spec.mjs');
const { decodePng, encodePng } = await import('./lib/png.mjs');
const { crop, resize } = await import('./lib/raster.mjs');
const specPath = arg('spec', SPEC_PATH);
const spec = loadSpec(specPath);
if (!spec) { console.error(`generate-image: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); }
const region = spec.regions.find((r) => r.id === plateId);
if (!region) { console.error(`generate-image: no region ${plateId} in ${specPath}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
if (region.medium !== 'raster') { console.error(`generate-image: region ${plateId} is ${region.medium}, not a plate; set its kind to plate|image|texture in the regions file`); process.exit(1); }
let comp;
try { comp = decodePng(fs.readFileSync(spec.comp)); } catch (e) { console.error(`generate-image: cannot read comp ${spec.comp}: ${e.message}`); process.exit(1); }
const ref = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
const refPath = path.join(path.dirname(specPath), 'crops', `${region.id}.png`);
fs.mkdirSync(path.dirname(refPath), { recursive: true });
fs.writeFileSync(refPath, encodePng(ref, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } }));
const out = arg('out', region.plate);
fs.mkdirSync(path.dirname(out), { recursive: true });
// closest supported size to the region's aspect; the page crops the rest with object-fit
const aspect = region.px.w / region.px.h;
const size = arg('size') || (aspect > 1.2 ? '1536x1024' : aspect < 0.83 ? '1024x1536' : '1024x1024');
const extra = arg('prompt') || (arg('prompt-file') ? fs.readFileSync(arg('prompt-file'), 'utf8') : '');
const prompt = [platePrompt(spec, region), extra].filter(Boolean).join(' ');
plateCtx = { spec, specPath, region, ref, refPath, out, size, prompt, comp, encodePng, resize };
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}.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);
}
// fall through to the real call below with the crop as the single --ref
}
async function scorePlate(ctx, outFile) {
try {
const { compare } = await import('./comp-diff.mjs');
const { decodePng } = await import('./lib/png.mjs');
const plate = decodePng(fs.readFileSync(outFile));
// a plate ships under object-fit: cover, so score it the way it will show
const res = compare({ comp: ctx.ref, build: plate, align: 'cover', kind: ctx.region.kind });
const s = res.whole;
const min = arg('min') ? parseFloat(arg('min')) : null;
const line = `PLATE-SCORE ${ctx.region.id} ${(s.overall * 100).toFixed(0)}% against the comp region (structure ${(s.structure * 100).toFixed(0)}%, color ${(s.color * 100).toFixed(0)}%, detail ${(s.detail * 100).toFixed(0)}%)`;
console.log(line);
if (s.overall < 0.5) console.log(`PLATE-WARN the plate does not read as region ${ctx.region.id}; open ${outFile} beside ${ctx.refPath} and regenerate with a stricter prompt (or pass a different --ref) before building on it.`);
if (min != null && s.overall < min) { console.log(`PLATE-REJECTED below --min ${(min * 100).toFixed(0)}%`); process.exit(3); }
} catch (e) {
console.log(`PLATE-SCORE unavailable: ${e.message}`);
}
}
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
const fakePromptFile = arg('prompt-file');
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
@@ -209,21 +279,21 @@ if (!key) {
process.exit(1);
}
const promptFile = arg('prompt-file');
const prompt = promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt');
const out = arg('out');
const prompt = plateCtx ? plateCtx.prompt : (promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt'));
const out = plateCtx ? plateCtx.out : arg('out');
if (!prompt || !out) {
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
process.exit(1);
}
const size = arg('size', '1536x1024');
const quality = arg('quality', 'medium');
const size = plateCtx ? plateCtx.size : arg('size', '1536x1024');
const quality = arg('quality', plateCtx ? 'high' : 'medium');
// Reference images (--ref, repeatable): route through the edits endpoint,
// which accepts input images. This is how a comp for an established world
// inherits the real UI's identity from a captured screenshot instead of a
// prose paraphrase of it; the prompt then describes the NEW surface and the
// reference carries palette, type, and component character.
const refs = (() => {
const found = [];
const found = plateCtx ? [plateCtx.refPath] : [];
for (let i = 0; i < process.argv.length; i += 1) {
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
}
@@ -275,3 +345,4 @@ try {
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
if (plateCtx) await scorePlate(plateCtx, out);
@@ -1127,3 +1127,20 @@ describe('detectUrl — browser-only fixtures', () => {
});
});
});
describe('detectUrl — comp-fidelity rules (browser adapter parity)', () => {
it('organic-clip-path fires in the browser on the same fixture', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/organic-clip-path.html`, { visualContrast: false });
const hits = f.filter(r => r.antipattern === 'organic-clip-path');
assert.equal(hits.length, 4, hits.map(h => h.snippet).join('\n'));
});
it('buried-raster fires in the browser for washes and near-zero opacity', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/buried-raster.html`, { visualContrast: false });
const snippets = f.filter(r => r.antipattern === 'buried-raster').map(h => h.snippet || '');
assert.equal(snippets.filter(s => /near-opaque gradient wash/.test(s)).length, 2, snippets.join('\n'));
assert.ok(snippets.some(s => /raster background at opacity 0.04/.test(s)), snippets.join('\n'));
assert.ok(snippets.some(s => /<img> at opacity 0.05/.test(s)), snippets.join('\n'));
assert.ok(!snippets.some(s => /hero\.jpg|opacity 0\.6|Faint text/.test(s)));
});
});
@@ -1563,3 +1563,32 @@ describe('detectHtml — dark themes written in modern color syntax', () => {
);
});
});
describe('detectHtml — organic-clip-path', () => {
const SHOULD_FLAG = ['polygon() with 18 vertices', 'polygon() with 21 vertices', 'path() with 6 curve segments'];
it('flags organic polygon/path clips and passes geometric clips', async () => {
const f = await detectHtml(path.join(FIXTURES, 'organic-clip-path.html'));
const hits = f.filter(r => r.antipattern === 'organic-clip-path');
// arch (18), blob (21), silhouette path, inline blob (21)
assert.equal(hits.length, 4, hits.map(h => h.snippet).join('\n'));
for (const text of SHOULD_FLAG) {
assert.ok(hits.some(h => (h.snippet || '').includes(text)), `expected a finding containing "${text}"`);
}
// geometric clips never mention themselves
for (const h of hits) assert.doesNotMatch(h.snippet, /with [5-9] vertices/);
});
});
describe('detectHtml — buried-raster', () => {
it('flags rasters under near-opaque washes and at near-zero opacity, passes tints, blends, and visible textures', async () => {
const f = await detectHtml(path.join(FIXTURES, 'buried-raster.html'));
const hits = f.filter(r => r.antipattern === 'buried-raster');
const snippets = hits.map(h => h.snippet || '');
assert.equal(snippets.filter(s => /near-opaque gradient wash/.test(s)).length, 2, snippets.join('\n'));
assert.ok(snippets.some(s => /raster background at opacity 0.04 "Grain"/.test(s)), snippets.join('\n'));
assert.ok(snippets.some(s => /<img> at opacity 0.05 "Ghost img"/.test(s)), snippets.join('\n'));
assert.equal(hits.length, 4, snippets.join('\n'));
// the passing shapes never appear
assert.ok(!snippets.some(s => /hero\.jpg|opacity 0\.6|multiply|Faint text/.test(s)));
});
});
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Buried raster fixture</title>
<style>
/* FLAG: a produced texture applied under a near-opaque wash; the wash ships, the texture does not */
.paper-wash { width: 800px; height: 400px; background-image: linear-gradient(rgba(240,237,226,0.96), rgba(240,237,226,0.96)), url(assets/manual-paper.png); }
/* FLAG: same, layered order reversed in a comma list with the wash first at 0.95 */
.paper-wash-b { width: 800px; height: 400px; background: linear-gradient(0deg, rgba(255,255,255,.95), rgba(255,255,255,.95)), url("assets/bone-paper-texture.png") center/cover; }
/* FLAG: raster at near-zero opacity via the opacity property */
.grain { width: 800px; height: 400px; background-image: url(assets/carbon-paper-texture.png); opacity: 0.04; }
/* FLAG: <img> at near-zero opacity */
.ghost-img { opacity: 0.05; }
/* PASS: a legible tint over a photo (hero darkening) */
.hero-tint { width: 800px; height: 400px; background-image: linear-gradient(rgba(0,0,0,0.45), rgba(0,0,0,0.45)), url(assets/hero.jpg); }
/* PASS: texture at readable opacity */
.grain-visible { width: 800px; height: 400px; background-image: url(assets/paper.png); opacity: 0.6; }
/* PASS: gradient-only background, no raster to bury */
.flat { width: 800px; height: 400px; background-image: linear-gradient(rgba(240,237,226,0.96), rgba(240,237,226,0.96)); }
/* PASS: multiply blend keeps the material visible even under a strong color */
.blend { width: 800px; height: 400px; background-image: linear-gradient(#f0ede2, #f0ede2), url(assets/paper.png); background-blend-mode: multiply; }
/* PASS: opacity on a container that has no raster */
.faint-text { opacity: 0.05; }
</style>
</head>
<body>
<section class="paper-wash">Paper wash</section>
<section class="paper-wash-b">Paper wash b</section>
<section class="grain">Grain</section>
<img class="ghost-img" src="assets/vellum.png" alt="Ghost img" width="400" height="300">
<section class="hero-tint">Hero tint</section>
<section class="grain-visible">Grain visible</section>
<section class="flat">Flat</section>
<section class="blend">Blend</section>
<p class="faint-text">Faint text</p>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Organic clip-path polygon fixture</title>
<style>
/* FLAG: a 17-vertex polygon approximating a torn-paper arch (the album run) */
.arch { width: 400px; height: 500px; background: #1a1a1a; clip-path: polygon(2% 100%, 0 51%, 1% 43%, 3% 35%, 7% 27%, 12% 20%, 19% 14%, 27% 9%, 37% 5%, 49% 1%, 59% 4%, 69% 8%, 78% 14%, 86% 21%, 92% 30%, 96% 39%, 98% 48%, 100% 100%); }
/* FLAG: a "blob" hero mask with a dozen points and no straight edges */
.blob { width: 320px; height: 320px; background: #c33; clip-path: polygon(50% 0%, 61% 8%, 74% 6%, 82% 16%, 94% 22%, 96% 36%, 100% 50%, 92% 63%, 88% 78%, 74% 88%, 60% 100%, 46% 96%, 30% 98%, 18% 86%, 6% 78%, 2% 62%, 0% 48%, 6% 34%, 12% 20%, 22% 10%, 36% 4%); }
/* FLAG: shorthand path() drawing a silhouette in CSS */
.silhouette { width: 300px; height: 400px; background: #222; clip-path: path('M150 0 C 180 40, 220 60, 230 120 C 240 180, 200 220, 210 280 C 220 340, 180 400, 150 400 C 120 400, 80 340, 90 280 C 100 220, 60 180, 70 120 C 80 60, 120 40, 150 0 Z'); }
/* FLAG: inline style variant */
/* PASS: rectangular / geometric clips */
.cut-corner { clip-path: polygon(0 0, 100% 0, 100% 85%, 85% 100%, 0 100%); }
.diagonal-band { clip-path: polygon(0 0, 100% 0, 100% 80%, 0 100%); }
.hexagon { clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%); }
.arrow { clip-path: polygon(0 20%, 60% 20%, 60% 0, 100% 50%, 60% 100%, 60% 80%, 0 80%); }
.chevron-eight { clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%, 25% 50%, 0 0, 0 0); }
/* PASS: circle / ellipse / inset primitives */
.avatar { clip-path: circle(50% at 50% 50%); }
.pill { clip-path: inset(0 round 999px); }
/* PASS: an SVG-drawn mask that comes from an alpha matte image, not a polygon */
.matte { mask-image: url(assets/cutout-alpha.png); }
</style>
</head>
<body>
<div class="arch">Arch</div>
<div class="blob">Blob</div>
<div class="silhouette">Silhouette</div>
<div style="width:200px;height:200px;background:#333;clip-path:polygon(48% 2%, 60% 6%, 71% 12%, 80% 21%, 88% 33%, 93% 46%, 94% 60%, 90% 73%, 82% 84%, 70% 93%, 56% 98%, 42% 97%, 29% 92%, 18% 83%, 10% 71%, 5% 58%, 5% 44%, 9% 31%, 17% 20%, 27% 11%, 38% 5%)">Inline blob</div>
<div class="cut-corner">Cut corner</div>
<div class="diagonal-band">Diagonal band</div>
<div class="hexagon">Hexagon</div>
<div class="arrow">Arrow</div>
<div class="chevron-eight">Chevron eight</div>
<div class="avatar">Avatar</div>
<div class="pill">Pill</div>
<div class="matte">Matte</div>
</body>
</html>