mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-16 08:06:24 +03:00
detector: grid-background variants, dash-prefix eyebrow, marquee rule, inset-shadow stripes
Four gaps from human review of gpt-5.6 eval artifacts: 1. codex-grid-background variants: the block scan now also matches the inverted end-of-tile hairline form (transparent calc(100% - Npx)) and reads the tile cell from the background shorthand's `/ Npx Npx` slot, not just background-size declarations. A single hairline layer qualifies when tiled by a px pair cell (page-scale line field); percent-tiled single hairlines (background-size: 25% 100% rules on data-viz tracks/graphs) stay legal. 2. hero-eyebrow-chip branch C (dash-prefix): sentence-case, regular- weight microlabels above the h1 announced by a short chromatic ::before/::after bar (8-80px x 1-6px, accent fill). Static cascade marks dash-pseudo targets during rule collection; the browser path reads getComputedStyle(el, '::before'/'::after'). 3. New `marquee` slop rule: <marquee> elements, and infinite animations bound to keyframes with >= 20 percentage points of X travel. Percent travel only — px-travel loops are bespoke product animations (waveform playheads, progress sweeps). Centered elements animating other properties (constant -50% X), non-infinite slide-ins, rotations, and pulses never qualify. 4. side-tab inset box-shadow variant: single-edge inset shadows (3-12px offset on one axis, no blur/spread, chromatic) drawn as stripes on cards/badges/menu items. Selection-state indicators ([aria-current], [aria-selected], [role=tab], active/current/selected hints, interaction states) stay exempt; the same stripe repeated unconditionally on every item flags. Narrow fixed-width glyphs (logo marks) are exempt. isTabContextElement narrowed to match: bare nav ancestry no longer blanket-exempts top/bottom border stripes — only explicit tab semantics or state markers do. Browser bundle regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5635b1d484
commit
ea44f514f9
@@ -233,6 +233,15 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'marquee',
|
||||
category: 'slop',
|
||||
name: 'Auto-scrolling marquee',
|
||||
description:
|
||||
'Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'auto-scrolling marquee',
|
||||
},
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
@@ -538,7 +547,7 @@ const ANTIPATTERNS = [
|
||||
gated: 'gpt',
|
||||
name: 'Decorative grid-line background',
|
||||
description:
|
||||
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
|
||||
'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'two-axis grid-line gradient background',
|
||||
},
|
||||
@@ -1039,6 +1048,7 @@ function checkHeroEyebrow(opts) {
|
||||
siblingTag, siblingText, siblingTextTransform,
|
||||
siblingFontSize, siblingLetterSpacing,
|
||||
siblingFontWeight, siblingColor,
|
||||
siblingHasAccentDashPseudo,
|
||||
} = opts;
|
||||
if (headingTag !== 'h1') return [];
|
||||
// We previously gated on headingFontSize >= 48 to anchor "hero scale".
|
||||
@@ -1070,11 +1080,16 @@ function checkHeroEyebrow(opts) {
|
||||
const weight = Number(siblingFontWeight) || 400;
|
||||
const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
|
||||
|
||||
if (!isClassicTracked && !isAccentBold) return [];
|
||||
// Branch C: dash-prefix eyebrow — sentence case, low tracking, regular
|
||||
// weight, but announced by a short chromatic ::before/::after bar
|
||||
// (the kicker dash). Same label-above-headline pattern, third styling.
|
||||
const isDashPrefixed = !!siblingHasAccentDashPseudo;
|
||||
|
||||
if (!isClassicTracked && !isAccentBold && !isDashPrefixed) return [];
|
||||
|
||||
const headingTextSnippet = (headingText || '').trim().slice(0, 60);
|
||||
const eyebrowSnippet = text.slice(0, 40);
|
||||
const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
|
||||
const style = isClassicTracked ? 'tracked-caps' : isAccentBold ? 'accent-bold' : 'dash-prefix';
|
||||
return [{
|
||||
id: 'hero-eyebrow-chip',
|
||||
snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
|
||||
@@ -1509,6 +1524,137 @@ function scanCssTextForPseudoStripe(content) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Side-tab stripe drawn as a single-edge inset box-shadow
|
||||
// (x or y offset 3-12px, other axis 0, no blur/spread, chromatic color):
|
||||
// paints a bar along one edge with no border property involved, so the
|
||||
// element-level border checks never see it. Selection-state indicators
|
||||
// are exempt — an inset stripe on [aria-current] / .active / [role=tab]
|
||||
// marks the selected item; the same stripe unconditionally on every item
|
||||
// is decoration and flags.
|
||||
function scanCssTextForInsetStripe(content) {
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const findings = [];
|
||||
const seen = new Set();
|
||||
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
|
||||
let m;
|
||||
while ((m = ruleRe.exec(content)) !== null) {
|
||||
const selector = m[1].trim();
|
||||
// State/selection contexts: current-item markers, interaction states,
|
||||
// explicit tab semantics.
|
||||
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
|
||||
if (/\[aria-(?:current|selected)/i.test(selector)) continue;
|
||||
if (/\[role=["']?tab/i.test(selector)) continue;
|
||||
if (/(?:^|[\s._[-])(?:active|current|selected|tabs?)(?![\w])/i.test(selector)) continue;
|
||||
// Structural tags where a single-edge inset shadow is depth/quoting,
|
||||
// not an accent stripe.
|
||||
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
|
||||
|
||||
const decls = parseCssDeclBlock(m[2]);
|
||||
const shadow = decls.get('box-shadow');
|
||||
if (!shadow || !/\binset\b/i.test(shadow)) continue;
|
||||
// Narrow fixed-width elements (logo marks, icon glyphs) use inset
|
||||
// fills as artwork, not edge stripes. Stripe targets — cards, badges,
|
||||
// menu items — are wider or leave width to layout.
|
||||
const declaredWidth = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', customProps));
|
||||
if (declaredWidth != null && declaredWidth <= 40) continue;
|
||||
const value = resolveVarRefs(shadow, customProps);
|
||||
for (const layer of value.split(/,(?![^(]*\))/)) {
|
||||
if (!/\binset\b/i.test(layer)) continue;
|
||||
const colorInfo = findShadowColor(layer);
|
||||
// Unresolvable colors (currentColor, external vars): don't guess.
|
||||
if (!colorInfo || !colorInfo.color) continue;
|
||||
const c = colorInfo.color;
|
||||
if ((c.a ?? 1) < 0.1) continue;
|
||||
const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
|
||||
if (chroma < 30) continue;
|
||||
const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
|
||||
const x = vals[0] || 0, y = vals[1] || 0, blur = vals[2] || 0, sp = vals[3] || 0;
|
||||
if (blur !== 0 || sp !== 0) continue;
|
||||
const ax = Math.abs(x), ay = Math.abs(y);
|
||||
const isStripe = (ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0);
|
||||
if (!isStripe) continue;
|
||||
if (seen.has(selector)) break;
|
||||
seen.add(selector);
|
||||
const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom');
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Collect @keyframes names whose body travels horizontally — the marquee
|
||||
// loop. X travel is measured across every translateX/translate/translate3d
|
||||
// X component in the body: a centered element animating something else
|
||||
// keeps a constant -50% X (zero travel) and never qualifies, while a
|
||||
// ticker moves from its resting position to a large offset. Keyframes
|
||||
// with a single X sample that also vary scale/opacity read as pulses or
|
||||
// breathes, not marquees.
|
||||
function collectMarqueeKeyframes(content) {
|
||||
const names = new Set();
|
||||
const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
let depth = 1;
|
||||
let i = re.lastIndex;
|
||||
while (i < content.length && depth > 0) {
|
||||
const ch = content.charCodeAt(i);
|
||||
if (ch === 0x7b /* { */) depth++;
|
||||
else if (ch === 0x7d /* } */) depth--;
|
||||
i++;
|
||||
}
|
||||
const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
|
||||
re.lastIndex = i;
|
||||
|
||||
// Only percentage travel qualifies: a content marquee translates by a
|
||||
// fraction of its own (unknown) track width, so generated tickers use
|
||||
// -50% / -100%. Pixel-travel loops are bespoke product animations —
|
||||
// sweeping playheads, progress indicators — not marquees.
|
||||
const pct = [];
|
||||
const xRe = /\btranslate(?:X|3d)?\(\s*(-?[\d.]+)%/gi;
|
||||
let xm;
|
||||
while ((xm = xRe.exec(body)) !== null) pct.push(parseFloat(xm[1]));
|
||||
if (pct.length === 0) continue;
|
||||
if (pct.length === 1 && /\bscale\(|\bopacity\s*:/i.test(body)) continue;
|
||||
// Implicit start: a lone declared X animates from the element's
|
||||
// resting position, so its magnitude is the travel.
|
||||
const travelPct = pct.length > 1 ? Math.max(...pct) - Math.min(...pct) : Math.abs(pct[0]);
|
||||
if (travelPct >= 20) names.add(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// Auto-scrolling marquee: a <marquee> element, or an infinite animation
|
||||
// bound to a keyframe loop that travels a large horizontal distance.
|
||||
// Rotation/opacity animations never qualify (no X travel); JS-driven
|
||||
// carousels with user controls have no infinite CSS X-loop to match.
|
||||
function scanCssTextForMarquee(content) {
|
||||
const findings = [];
|
||||
if (/<marquee\b/i.test(content)) {
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element' });
|
||||
}
|
||||
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
||||
if (marqueeKeyframes.size === 0) return findings;
|
||||
const seen = new Set();
|
||||
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
|
||||
let m;
|
||||
while ((m = ruleRe.exec(content)) !== null) {
|
||||
const selector = m[1].trim();
|
||||
const decls = parseCssDeclBlock(m[2]);
|
||||
for (const name of infiniteAnimationNames(decls)) {
|
||||
if (!marqueeKeyframes.has(name)) continue;
|
||||
const key = `${selector} | ||||