mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
detector: catch pseudo-element side stripes; add pulsing-dot rule
Two gaps surfaced by human eval review of real artifacts: 1. side-tab missed the pseudo-element variant. The accent stripe drawn as an absolutely-positioned ::before/::after (left/right: 0, top+bottom: 0 or height: 100%, narrow width, colored background) uses no border property at all, so neither the element-level border checks (pseudo elements never enter the static cascade or DOM walk) nor the border-left/right regexes could see it. New scanCssTextForPseudoStripe scans stylesheet text for that shape, mirroring the border rule's gates: >= 3px thick (<= 12px), chromatic fill (var()-resolved, neutral dividers skipped), full height against a side edge, with the blockquote/prose exemptions preserved. 2. New pulsing-dot rule (slop): small circular "live" indicator dots (<= 16px, border-radius >= 40% or pill values) bound to an infinite animation whose keyframes vary opacity, scale, or box-shadow — or pulse/blink/ping names when the keyframes aren't in the scanned text — plus the Tailwind animate-ping/pulse + rounded-full + tiny-size utility combo. Rotation-only keyframes (spinners) never flag, including when they hide behind a pulse-like name. Both scanners live in checkHtmlPatterns, so the static-html engine and the browser bundle share the same detection path. Browser/extension bundles regenerated; docs rule count bumped to 47. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
eeff485c20
commit
cfdb7d4c81
@@ -1,6 +1,6 @@
|
||||
# Impeccable
|
||||
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 46 deterministic detector rules for AI-generated frontend design.
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 47 deterministic detector rules for AI-generated frontend design.
|
||||
|
||||
> **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
|
||||
|
||||
@@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th
|
||||
Impeccable adds:
|
||||
- **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components.
|
||||
- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more.
|
||||
- **46 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
- **47 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
|
||||
## What's Included
|
||||
|
||||
@@ -357,7 +357,7 @@ npx impeccable ignores add-file "src/legacy/**"
|
||||
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
```
|
||||
|
||||
The detector catches 46 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
|
||||
The detector catches 47 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
|
||||
|
||||
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Impeccable CLI
|
||||
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 46 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 47 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -56,7 +56,7 @@ npx impeccable detect --fast src/
|
||||
|
||||
**Quality**: tiny body text, cramped padding, long line lengths, small touch targets
|
||||
|
||||
46 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
|
||||
47 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
|
||||
|
||||
## Exit Codes
|
||||
|
||||
|
||||
@@ -206,6 +206,15 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'bounce or elastic easing',
|
||||
},
|
||||
{
|
||||
id: 'pulsing-dot',
|
||||
category: 'slop',
|
||||
name: 'Pulsing status dot',
|
||||
description:
|
||||
'Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'decorative pulsing status dot',
|
||||
},
|
||||
{
|
||||
id: 'dark-glow',
|
||||
category: 'slop',
|
||||
@@ -1233,6 +1242,253 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Iterate `selector { declarations }` pairs in raw CSS/HTML text. The block
|
||||
// body excludes braces, so nested structures (@media, @keyframes) naturally
|
||||
// yield their innermost rules with the innermost selector text. Callers
|
||||
// create the regex locally — a shared /g instance is not re-entrant.
|
||||
const CSS_RULE_BLOCK_SOURCE = String.raw`([^{};]+)\{([^{}]*)\}`;
|
||||
|
||||
// Parse a declaration block into a prop → value map (last declaration wins,
|
||||
// approximating the cascade inside one block). Values keep their raw text
|
||||
// with any !important suffix stripped.
|
||||
function parseCssDeclBlock(block) {
|
||||
const decls = new Map();
|
||||
for (const part of String(block || '').split(';')) {
|
||||
const idx = part.indexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
const prop = part.slice(0, idx).trim().toLowerCase();
|
||||
const value = part.slice(idx + 1).replace(/\s*!important\s*$/i, '').trim();
|
||||
if (prop && value) decls.set(prop, value);
|
||||
}
|
||||
return decls;
|
||||
}
|
||||
|
||||
function cssLengthToPx(value) {
|
||||
const m = String(value || '').trim().match(/^(-?[\d.]+)(px|rem|em)$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]);
|
||||
return m[2].toLowerCase() === 'px' ? n : n * 16;
|
||||
}
|
||||
|
||||
function isZeroOffset(value) {
|
||||
return value != null && /^-?0(?:px|%|rem|em)?$/.test(String(value).trim());
|
||||
}
|
||||
|
||||
// Side-tab variant: the accent stripe drawn as an absolutely-positioned
|
||||
// ::before/::after pseudo-element (narrow colored box hugging a vertical
|
||||
// edge) instead of a border-left/right. The element-level border checks
|
||||
// never see it — pseudo-elements aren't part of the DOM the cascade walks —
|
||||
// so this scans stylesheet text directly, mirroring the border rule's
|
||||
// gates: >= 3px thick, chromatic fill, full height against a side edge.
|
||||
function scanCssTextForPseudoStripe(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();
|
||||
if (!/::?(?:before|after)\b/i.test(selector)) continue;
|
||||
// Keep the border rule's prose exemptions (blockquote bars etc.).
|
||||
if (/\b(?:blockquote|pre|code|nav|hr)\b/i.test(selector)) continue;
|
||||
const decls = parseCssDeclBlock(m[2]);
|
||||
const position = decls.get('position');
|
||||
if (position !== 'absolute' && position !== 'fixed') continue;
|
||||
|
||||
const widthPx = cssLengthToPx(resolveVarRefs(
|
||||
decls.get('width') || decls.get('inline-size') || '', customProps));
|
||||
if (widthPx == null || widthPx < 3 || widthPx > 12) continue;
|
||||
|
||||
// Resolve edge offsets, letting an `inset` shorthand fill the gaps.
|
||||
const offsets = {
|
||||
top: decls.get('top'), right: decls.get('right'),
|
||||
bottom: decls.get('bottom'), left: decls.get('left'),
|
||||
};
|
||||
const inset = decls.get('inset');
|
||||
if (inset) {
|
||||
const p = inset.split(/\s+/);
|
||||
const [t, r, b, l] =
|
||||
p.length === 1 ? [p[0], p[0], p[0], p[0]]
|
||||
: p.length === 2 ? [p[0], p[1], p[0], p[1]]
|
||||
: p.length === 3 ? [p[0], p[1], p[2], p[1]]
|
||||
: p;
|
||||
if (offsets.top == null) offsets.top = t;
|
||||
if (offsets.right == null) offsets.right = r;
|
||||
if (offsets.bottom == null) offsets.bottom = b;
|
||||
if (offsets.left == null) offsets.left = l;
|
||||
}
|
||||
if (offsets.left == null) offsets.left = decls.get('inset-inline-start');
|
||||
if (offsets.right == null) offsets.right = decls.get('inset-inline-end');
|
||||
|
||||
const heightValue = String(resolveVarRefs(
|
||||
decls.get('height') || decls.get('block-size') || '', customProps)).trim();
|
||||
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|
||||
|| /^100(?:\.0*)?%$/.test(heightValue);
|
||||
if (!fullHeight) continue;
|
||||
const edge = isZeroOffset(offsets.left) ? 'left'
|
||||
: isZeroOffset(offsets.right) ? 'right' : null;
|
||||
if (!edge) continue;
|
||||
|
||||
// Chromatic fill only — a neutral hairline divider is not an accent
|
||||
// stripe. Unresolvable colors err toward detection, matching the
|
||||
// border rule's unknown-format default.
|
||||
const bg = String(resolveVarRefs(
|
||||
decls.get('background-color') || decls.get('background') || '', customProps)).trim();
|
||||
if (!bg || /^(?:none|transparent|inherit|initial|unset|currentcolor)$/i.test(bg)) continue;
|
||||
const colorToken = bg.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)|#[0-9a-f]{3,8}\b/i);
|
||||
const parsed = parseAnyColor(colorToken ? colorToken[0] : bg);
|
||||
if (parsed) {
|
||||
if ((parsed.a ?? 1) < 0.1) continue;
|
||||
const spread = Math.max(parsed.r, parsed.g, parsed.b) - Math.min(parsed.r, parsed.g, parsed.b);
|
||||
if (spread < 30) continue;
|
||||
} else if (/^(?:white|black|gray|grey|silver)$/i.test(bg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(selector)) continue;
|
||||
seen.add(selector);
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${widthPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Collect @keyframes names and whether each one reads as a "pulse" —
|
||||
// i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes
|
||||
// (spinners) are explicitly not pulses.
|
||||
function collectPulseKeyframes(content) {
|
||||
const map = new Map();
|
||||
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));
|
||||
const pulses = /\bopacity\s*:/i.test(body)
|
||||
|| /\bbox-shadow\s*:/i.test(body)
|
||||
|| /\btransform\s*:[^;{}]*\bscale/i.test(body);
|
||||
if (!map.has(m[1]) || pulses) map.set(m[1], pulses);
|
||||
re.lastIndex = i;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const ANIMATION_VALUE_KEYWORDS = new Set([
|
||||
'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear',
|
||||
'infinite', 'alternate', 'alternate-reverse', 'normal', 'reverse',
|
||||
'none', 'forwards', 'backwards', 'both', 'running', 'paused',
|
||||
'step-start', 'step-end', 'inherit', 'initial', 'unset',
|
||||
]);
|
||||
|
||||
// Extract animation names that run with iteration-count: infinite from a
|
||||
// declaration block (shorthand layers or animation-name + iteration-count).
|
||||
function infiniteAnimationNames(decls) {
|
||||
const out = [];
|
||||
const shorthand = decls.get('animation');
|
||||
if (shorthand) {
|
||||
for (const layer of shorthand.split(/,(?![^(]*\))/)) {
|
||||
if (!/\binfinite\b/i.test(layer)) continue;
|
||||
const name = layer.split(/\s+/).find(t =>
|
||||
/^[a-zA-Z_-][\w-]*$/.test(t) && !ANIMATION_VALUE_KEYWORDS.has(t.toLowerCase()));
|
||||
if (name) out.push(name);
|
||||
}
|
||||
}
|
||||
const nameDecl = decls.get('animation-name');
|
||||
if (nameDecl && /\binfinite\b/i.test(decls.get('animation-iteration-count') || '')) {
|
||||
for (const raw of nameDecl.split(',')) {
|
||||
const t = raw.trim();
|
||||
if (t && t.toLowerCase() !== 'none') out.push(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isRoundDotRadius(radiusValue, w, h) {
|
||||
if (!radiusValue) return false;
|
||||
const first = String(radiusValue).trim().split(/\s+/)[0];
|
||||
const pct = first.match(/^([\d.]+)%$/);
|
||||
if (pct) return parseFloat(pct[1]) >= 40;
|
||||
const px = cssLengthToPx(first);
|
||||
if (px == null) return false;
|
||||
return px >= 999 || px >= 0.4 * Math.min(w, h);
|
||||
}
|
||||
|
||||
// Small circular indicator bound to an infinite pulse animation — the
|
||||
// decorative "live" dot. Gates: tiny (<= 16px square-ish), round
|
||||
// (border-radius >= 40% or pill values), and an infinite animation whose
|
||||
// keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
|
||||
// the keyframes aren't in the scanned text). Rotation-only animations
|
||||
// (spinners) never flag.
|
||||
function scanCssTextForPulsingDot(content) {
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const keyframes = collectPulseKeyframes(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();
|
||||
const decls = parseCssDeclBlock(m[2]);
|
||||
const names = infiniteAnimationNames(decls);
|
||||
if (names.length === 0) continue;
|
||||
const pulseName = names.find(n => {
|
||||
const known = keyframes.get(n);
|
||||
if (known != null) return known;
|
||||
return /pulse|blink|ping/i.test(n);
|
||||
});
|
||||
if (!pulseName) continue;
|
||||
|
||||
const w = cssLengthToPx(resolveVarRefs(
|
||||
decls.get('width') || decls.get('inline-size') || '', customProps));
|
||||
const h = cssLengthToPx(resolveVarRefs(
|
||||
decls.get('height') || decls.get('block-size') || '', customProps));
|
||||
if (w == null || h == null || w < 2 || h < 2 || w > 16 || h > 16) continue;
|
||||
|
||||
const radius = resolveVarRefs(decls.get('border-radius') || '', customProps);
|
||||
if (!isRoundDotRadius(radius, w, h)) continue;
|
||||
|
||||
if (seen.has(selector)) continue;
|
||||
seen.add(selector);
|
||||
findings.push({
|
||||
id: 'pulsing-dot',
|
||||
snippet: `${selector} — ${w}x${h}px dot with infinite "${pulseName}" animation`,
|
||||
});
|
||||
}
|
||||
|
||||
// Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full
|
||||
// element declared entirely in the class attribute.
|
||||
const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
||||
let cm;
|
||||
while ((cm = classRe.exec(content)) !== null) {
|
||||
const cls = cm[1] || cm[2] || '';
|
||||
const anim = cls.match(/\banimate-(ping|pulse)\b/);
|
||||
if (!anim) continue;
|
||||
if (!/\brounded-full\b/.test(cls)) continue;
|
||||
if (!/\b(?:w|h|size)-(?:1|1\.5|2|2\.5|3|3\.5|4)\b/.test(cls)) continue;
|
||||
const key = `tw:${cls}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push({
|
||||
id: 'pulsing-dot',
|
||||
snippet: `animate-${anim[1]} on tiny rounded-full element`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex-on-HTML checks shared between browser and Node page-level detection.
|
||||
* These don't need DOM access, just the raw HTML string.
|
||||
@@ -1266,6 +1522,13 @@ function checkHtmlPatterns(html) {
|
||||
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
|
||||
}
|
||||
|
||||
// --- Borders ---
|
||||
|
||||
// Side-tab accent stripe drawn as an absolutely-positioned pseudo-element
|
||||
// (no border property involved, so the element-level border checks and
|
||||
// the border-left regexes never see it).
|
||||
findings.push(...scanCssTextForPseudoStripe(html));
|
||||
|
||||
// --- Layout ---
|
||||
|
||||
// Monotonous spacing
|
||||
@@ -1341,6 +1604,9 @@ function checkHtmlPatterns(html) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pulsing status dots (tiny circular elements on infinite pulse animations)
|
||||
findings.push(...scanCssTextForPulsingDot(html));
|
||||
|
||||
// --- Dark glow / chromatic halo shadows ---
|
||||
|
||||
const glowHits = scanCssTextForGlow(html);
|
||||
|
||||
@@ -104,6 +104,15 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'bounce or elastic easing',
|
||||
},
|
||||
{
|
||||
id: 'pulsing-dot',
|
||||
category: 'slop',
|
||||
name: 'Pulsing status dot',
|
||||
description:
|
||||
'Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'decorative pulsing status dot',
|
||||
},
|
||||
{
|
||||
id: 'dark-glow',
|
||||
category: 'slop',
|
||||
|
||||
@@ -559,6 +559,253 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Iterate `selector { declarations }` pairs in raw CSS/HTML text. The block
|
||||
// body excludes braces, so nested structures (@media, @keyframes) naturally
|
||||
// yield their innermost rules with the innermost selector text. Callers
|
||||
// create the regex locally — a shared /g instance is not re-entrant.
|
||||
const CSS_RULE_BLOCK_SOURCE = String.raw`([^{};]+)\{([^{}]*)\}`;
|
||||
|
||||
// Parse a declaration block into a prop → value map (last declaration wins,
|
||||
// approximating the cascade inside one block). Values keep their raw text
|
||||
// with any !important suffix stripped.
|
||||
function parseCssDeclBlock(block) {
|
||||
const decls = new Map();
|
||||
for (const part of String(block || '').split(';')) {
|
||||
const idx = part.indexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
const prop = part.slice(0, idx).trim().toLowerCase();
|
||||
const value = part.slice(idx + 1).replace(/\s*!important\s*$/i, '').trim();
|
||||
if (prop && value) decls.set(prop, value);
|
||||
}
|
||||
return decls;
|
||||
}
|
||||
|
||||
function cssLengthToPx(value) {
|
||||
const m = String(value || '').trim().match(/^(-?[\d.]+)(px|rem|em)$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]);
|
||||
return m[2].toLowerCase() === 'px' ? n : n * 16;
|
||||
}
|
||||
|
||||
function isZeroOffset(value) {
|
||||
return value != null && /^-?0(?:px|%|rem|em)?$/.test(String(value).trim());
|
||||
}
|
||||
|
||||
// Side-tab variant: the accent stripe drawn as an absolutely-positioned
|
||||
// ::before/::after pseudo-element (narrow colored box hugging a vertical
|
||||
// edge) instead of a border-left/right. The element-level border checks
|
||||
// never see it — pseudo-elements aren't part of the DOM the cascade walks —
|
||||
// so this scans stylesheet text directly, mirroring the border rule's
|
||||
// gates: >= 3px thick, chromatic fill, full height against a side edge.
|
||||
function scanCssTextForPseudoStripe(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();
|
||||
if (!/::?(?:before|after)\b/i.test(selector)) continue;
|
||||
// Keep the border rule's prose exemptions (blockquote bars etc.).
|
||||
if (/\b(?:blockquote|pre|code|nav|hr)\b/i.test(selector)) continue;
|
||||
const decls = parseCssDeclBlock(m[2]);
|
||||
const position = decls.get('position');
|
||||
if (position !== 'absolute' && position !== 'fixed') continue;
|
||||
|
||||
const widthPx = cssLengthToPx(resolveVarRefs(
|
||||
decls.get('width') || decls.get('inline-size') || '', customProps));
|
||||
if (widthPx == null || widthPx < 3 || widthPx > 12) continue;
|
||||
|
||||
// Resolve edge offsets, letting an `inset` shorthand fill the gaps.
|
||||
const offsets = {
|
||||
top: decls.get('top'), right: decls.get('right'),
|
||||
bottom: decls.get('bottom'), left: decls.get('left'),
|
||||
};
|
||||
const inset = decls.get('inset');
|
||||
if (inset) {
|
||||
const p = inset.split(/\s+/);
|
||||
const [t, r, b, l] =
|
||||
p.length === 1 ? [p[0], p[0], p[0], p[0]]
|
||||
: p.length === 2 ? [p[0], p[1], p[0], p[1]]
|
||||
: p.length === 3 ? [p[0], p[1], p[2], p[1]]
|
||||
: p;
|
||||
if (offsets.top == null) offsets.top = t;
|
||||
if (offsets.right == null) offsets.right = r;
|
||||
if (offsets.bottom == null) offsets.bottom = b;
|
||||
if (offsets.left == null) offsets.left = l;
|
||||
}
|
||||
if (offsets.left == null) offsets.left = decls.get('inset-inline-start');
|
||||
if (offsets.right == null) offsets.right = decls.get('inset-inline-end');
|
||||
|
||||
const heightValue = String(resolveVarRefs(
|
||||
decls.get('height') || decls.get('block-size') || '', customProps)).trim();
|
||||
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|
||||
|| /^100(?:\.0*)?%$/.test(heightValue);
|
||||
if (!fullHeight) continue;
|
||||
const edge = isZeroOffset(offsets.left) ? 'left'
|
||||
: isZeroOffset(offsets.right) ? 'right' : null;
|
||||
if (!edge) continue;
|
||||
|
||||
// Chromatic fill only — a neutral hairline divider is not an accent
|
||||
// stripe. Unresolvable colors err toward detection, matching the
|
||||
// border rule's unknown-format default.
|
||||
const bg = String(resolveVarRefs(
|
||||
decls.get('background-color') || decls.get('background') || '', customProps)).trim();
|
||||
if (!bg || /^(?:none|transparent|inherit|initial|unset|currentcolor)$/i.test(bg)) continue;
|
||||
const colorToken = bg.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)|#[0-9a-f]{3,8}\b/i);
|
||||
const parsed = parseAnyColor(colorToken ? colorToken[0] : bg);
|
||||
if (parsed) {
|
||||
if ((parsed.a ?? 1) < 0.1) continue;
|
||||
const spread = Math.max(parsed.r, parsed.g, parsed.b) - Math.min(parsed.r, parsed.g, parsed.b);
|
||||
if (spread < 30) continue;
|
||||
} else if (/^(?:white|black|gray|grey|silver)$/i.test(bg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(selector)) continue;
|
||||
seen.add(selector);
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${widthPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Collect @keyframes names and whether each one reads as a "pulse" —
|
||||
// i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes
|
||||
// (spinners) are explicitly not pulses.
|
||||
function collectPulseKeyframes(content) {
|
||||
const map = new Map();
|
||||
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));
|
||||
const pulses = /\bopacity\s*:/i.test(body)
|
||||
|| /\bbox-shadow\s*:/i.test(body)
|
||||
|| /\btransform\s*:[^;{}]*\bscale/i.test(body);
|
||||
if (!map.has(m[1]) || pulses) map.set(m[1], pulses);
|
||||
re.lastIndex = i;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const ANIMATION_VALUE_KEYWORDS = new Set([
|
||||
'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear',
|
||||
'infinite', 'alternate', 'alternate-reverse', 'normal', 'reverse',
|
||||
'none', 'forwards', 'backwards', 'both', 'running', 'paused',
|
||||
'step-start', 'step-end', 'inherit', 'initial', 'unset',
|
||||
]);
|
||||
|
||||
// Extract animation names that run with iteration-count: infinite from a
|
||||
// declaration block (shorthand layers or animation-name + iteration-count).
|
||||
function infiniteAnimationNames(decls) {
|
||||
const out = [];
|
||||
const shorthand = decls.get('animation');
|
||||
if (shorthand) {
|
||||
for (const layer of shorthand.split(/,(?![^(]*\))/)) {
|
||||
if (!/\binfinite\b/i.test(layer)) continue;
|
||||
const name = layer.split(/\s+/).find(t =>
|
||||
/^[a-zA-Z_-][\w-]*$/.test(t) && !ANIMATION_VALUE_KEYWORDS.has(t.toLowerCase()));
|
||||
if (name) out.push(name);
|
||||
}
|
||||
}
|
||||
const nameDecl = decls.get('animation-name');
|
||||
if (nameDecl && /\binfinite\b/i.test(decls.get('animation-iteration-count') || '')) {
|
||||
for (const raw of nameDecl.split(',')) {
|
||||
const t = raw.trim();
|
||||
if (t && t.toLowerCase() !== 'none') out.push(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isRoundDotRadius(radiusValue, w, h) {
|
||||
if (!radiusValue) return false;
|
||||
const first = String(radiusValue).trim().split(/\s+/)[0];
|
||||
const pct = first.match(/^([\d.]+)%$/);
|
||||
if (pct) return parseFloat(pct[1]) >= 40;
|
||||
const px = cssLengthToPx(first);
|
||||
if (px == null) return false;
|
||||
return px >= 999 || px >= 0.4 * Math.min(w, h);
|
||||
}
|
||||
|
||||
// Small circular indicator bound to an infinite pulse animation — the
|
||||
// decorative "live" dot. Gates: tiny (<= 16px square-ish), round
|
||||
// (border-radius >= 40% or pill values), and an infinite animation whose
|
||||
// keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
|
||||
// the keyframes aren't in the scanned text). Rotation-only animations
|
||||
// (spinners) never flag.
|
||||
function scanCssTextForPulsingDot(content) {
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const keyframes = collectPulseKeyframes(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();
|
||||
const decls = parseCssDeclBlock(m[2]);
|
||||
const names = infiniteAnimationNames(decls);
|
||||
if (names.length === 0) continue;
|
||||
const pulseName = names.find(n => {
|
||||
const known = keyframes.get(n);
|
||||
if (known != null) return known;
|
||||
return /pulse|blink|ping/i.test(n);
|
||||
});
|
||||
if (!pulseName) continue;
|
||||
|
||||
const w = cssLengthToPx(resolveVarRefs(
|
||||
decls.get('width') || decls.get('inline-size') || '', customProps));
|
||||
const h = cssLengthToPx(resolveVarRefs(
|
||||
decls.get('height') || decls.get('block-size') || '', customProps));
|
||||
if (w == null || h == null || w < 2 || h < 2 || w > 16 || h > 16) continue;
|
||||
|
||||
const radius = resolveVarRefs(decls.get('border-radius') || '', customProps);
|
||||
if (!isRoundDotRadius(radius, w, h)) continue;
|
||||
|
||||
if (seen.has(selector)) continue;
|
||||
seen.add(selector);
|
||||
findings.push({
|
||||
id: 'pulsing-dot',
|
||||
snippet: `${selector} — ${w}x${h}px dot with infinite "${pulseName}" animation`,
|
||||
});
|
||||
}
|
||||
|
||||
// Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full
|
||||
// element declared entirely in the class attribute.
|
||||
const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
||||
let cm;
|
||||
while ((cm = classRe.exec(content)) !== null) {
|
||||
const cls = cm[1] || cm[2] || '';
|
||||
const anim = cls.match(/\banimate-(ping|pulse)\b/);
|
||||
if (!anim) continue;
|
||||
if (!/\brounded-full\b/.test(cls)) continue;
|
||||
if (!/\b(?:w|h|size)-(?:1|1\.5|2|2\.5|3|3\.5|4)\b/.test(cls)) continue;
|
||||
const key = `tw:${cls}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push({
|
||||
id: 'pulsing-dot',
|
||||
snippet: `animate-${anim[1]} on tiny rounded-full element`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex-on-HTML checks shared between browser and Node page-level detection.
|
||||
* These don't need DOM access, just the raw HTML string.
|
||||
@@ -592,6 +839,13 @@ function checkHtmlPatterns(html) {
|
||||
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
|
||||
}
|
||||
|
||||
// --- Borders ---
|
||||
|
||||
// Side-tab accent stripe drawn as an absolutely-positioned pseudo-element
|
||||
// (no border property involved, so the element-level border checks and
|
||||
// the border-left regexes never see it).
|
||||
findings.push(...scanCssTextForPseudoStripe(html));
|
||||
|
||||
// --- Layout ---
|
||||
|
||||
// Monotonous spacing
|
||||
@@ -667,6 +921,9 @@ function checkHtmlPatterns(html) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pulsing status dots (tiny circular elements on infinite pulse animations)
|
||||
findings.push(...scanCssTextForPulsingDot(html));
|
||||
|
||||
// --- Dark glow / chromatic halo shadows ---
|
||||
|
||||
const glowHits = scanCssTextForGlow(html);
|
||||
@@ -2870,6 +3127,8 @@ export {
|
||||
checkMotion,
|
||||
checkGlow,
|
||||
scanCssTextForGlow,
|
||||
scanCssTextForPseudoStripe,
|
||||
scanCssTextForPulsingDot,
|
||||
checkHtmlPatterns,
|
||||
readOwnBackgroundColor,
|
||||
resolveBackground,
|
||||
|
||||
@@ -521,7 +521,7 @@ import '../styles/testimonials.css';
|
||||
<article class="ks-bento-tile ks-bento-tile--span-6" id="why-ci">
|
||||
<span class="ks-bento-num" data-color="patina">06</span>
|
||||
<h3 class="why-panel-title">Block slop before it ships.</h3>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 46 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 47 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<div class="why-visual why-visual--ci">
|
||||
<div class="why-ci-window">
|
||||
<div class="why-ci-header">
|
||||
@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
|
||||
</li>
|
||||
<li>
|
||||
<strong>CLI for CI</strong>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 46 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 47 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<a href="https://www.npmjs.com/package/impeccable" target="_blank" rel="noopener">View on npm →</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
checkElementTextOverflowDOM,
|
||||
checkPageTypography,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
scanCssTextForPseudoStripe,
|
||||
scanCssTextForPulsingDot,
|
||||
} from '../cli/engine/rules/checks.mjs';
|
||||
|
||||
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
||||
@@ -982,6 +984,166 @@ describe('detectHtml — static HTML/CSS engine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-tab as absolutely-positioned pseudo-element stripe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('side-tab — pseudo-element stripe variant', () => {
|
||||
test('fixture flags both stripe variants and nothing else', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'pseudo-stripe.html'));
|
||||
const stripes = f.filter(r => r.antipattern === 'side-tab');
|
||||
const snippets = stripes.map(r => r.snippet).join(' | ');
|
||||
expect(stripes).toHaveLength(2);
|
||||
expect(snippets).toContain('.card-stripe::before');
|
||||
expect(snippets).toContain('.row-stripe::after');
|
||||
});
|
||||
|
||||
test('detects ::before stripe with var() background resolved to chromatic', () => {
|
||||
const css = `
|
||||
:root { --accent: oklch(0.78 0.145 155); }
|
||||
.hero::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--accent); }
|
||||
`;
|
||||
const f = scanCssTextForPseudoStripe(css);
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].id).toBe('side-tab');
|
||||
expect(f[0].snippet).toContain('.hero::before');
|
||||
});
|
||||
|
||||
test('detects height:100% + right:0 variant', () => {
|
||||
const css = '.card::after { position: absolute; right: 0; top: 0; height: 100%; width: 4px; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('unresolvable custom-property color errs toward detection', () => {
|
||||
const css = '.card::before { position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--from-external-sheet); }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('skips neutral hairline divider', () => {
|
||||
const css = '.col::before { position: absolute; left: 0; top: 0; bottom: 0; width: 1px; background: rgba(0,0,0,0.08); }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips neutral 4px rail (chromatic gate)', () => {
|
||||
const css = '.timeline::before { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: rgb(209, 213, 219); }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips 2px stripe below width threshold', () => {
|
||||
const css = '.card::before { position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips blockquote pseudo decoration', () => {
|
||||
const css = 'blockquote::before { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #d97706; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips non-edge-anchored pseudo (toggle knob)', () => {
|
||||
const css = '.switch::before { position: absolute; left: 2px; top: 2px; width: 10px; height: 10px; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips full-overlay pseudo (inset: 0, no narrow width)', () => {
|
||||
const css = '.hero::after { position: absolute; inset: 0; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pulsing status dots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pulsing-dot', () => {
|
||||
test('fixture flags the four pulsing dots and none of the passes', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'pulsing-dot.html'));
|
||||
const dots = f.filter(r => r.antipattern === 'pulsing-dot');
|
||||
const snippets = dots.map(r => r.snippet).join(' | ');
|
||||
expect(dots).toHaveLength(4);
|
||||
expect(snippets).toContain('.live-dot');
|
||||
expect(snippets).toContain('.status .dot');
|
||||
expect(snippets).toContain('.beacon');
|
||||
expect(snippets).toContain('animate-ping');
|
||||
expect(snippets).not.toContain('spinner');
|
||||
expect(snippets).not.toContain('fake-pulse');
|
||||
expect(snippets).not.toContain('breathing-card');
|
||||
expect(snippets).not.toContain('square-badge');
|
||||
});
|
||||
|
||||
test('detects tiny circle with infinite opacity-pulse keyframes', () => {
|
||||
const css = `
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
`;
|
||||
const f = scanCssTextForPulsingDot(css);
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].id).toBe('pulsing-dot');
|
||||
});
|
||||
|
||||
test('detects box-shadow ripple keyframes', () => {
|
||||
const css = `
|
||||
.dot { width: 7px; height: 7px; border-radius: 999px; animation: ripple 1.8s linear infinite; }
|
||||
@keyframes ripple { 0% { box-shadow: 0 0 0 0 rgba(0,255,0,0.4); } 100% { box-shadow: 0 0 0 6px rgba(0,255,0,0); } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('accepts pulse-family names when keyframes are not in the scanned text', () => {
|
||||
const css = '.dot { width: 8px; height: 8px; border-radius: 50%; animation: blink 1.4s infinite; }';
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rotation-only animations never flag (spinners)', () => {
|
||||
const css = `
|
||||
.spinner { width: 14px; height: 14px; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rotation-only keyframes win over a pulse-like name', () => {
|
||||
const css = `
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse-ring 1s linear infinite; }
|
||||
@keyframes pulse-ring { to { transform: rotate(180deg); } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips large pulsing surfaces (not a dot)', () => {
|
||||
const css = `
|
||||
.card { width: 240px; height: 120px; border-radius: 16px; animation: pulse 3s infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips finite pulse animations', () => {
|
||||
const css = `
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 0.6s ease-out 3; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips non-circular pulsing elements', () => {
|
||||
const css = `
|
||||
.badge { width: 12px; height: 12px; border-radius: 2px; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Tailwind animate-ping on tiny rounded-full element flags; large skeleton does not', () => {
|
||||
const html = `
|
||||
<span class="animate-ping w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
<div class="animate-pulse rounded-md w-48 h-6 bg-gray-200"></div>
|
||||
`;
|
||||
const f = scanCssTextForPulsingDot(html);
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].snippet).toContain('animate-ping');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANTIPATTERNS registry
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pseudo-element side stripe fixture</title>
|
||||
<style>
|
||||
:root {
|
||||
--ok: oklch(0.78 0.145 155);
|
||||
--line: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* FLAG: classic ::before accent stripe — absolute, full height, left edge,
|
||||
chromatic background resolved through a custom property. */
|
||||
.card-stripe { position: relative; border-radius: 12px; }
|
||||
.card-stripe::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 5px;
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
/* FLAG: right-edge variant using inset shorthand + literal color. */
|
||||
.row-stripe { position: relative; }
|
||||
.row-stripe::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
width: 4px;
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* PASS: 1px neutral hairline divider (below width threshold + neutral). */
|
||||
.col + .col::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
/* PASS: neutral 4px rail (timeline spine) — chromatic gate rejects it. */
|
||||
.timeline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: rgb(210, 210, 214);
|
||||
}
|
||||
|
||||
/* PASS: blockquote decoration is exempt in prose contexts. */
|
||||
blockquote::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
/* PASS: toggle knob — offset from the edge, not a stripe. */
|
||||
.switch::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px; top: 2px;
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* PASS: wide decorative panel, not a stripe. */
|
||||
.panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 40px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* PASS: horizontal underline accent (not full height, not edge-anchored
|
||||
vertically). */
|
||||
.heading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; bottom: 0;
|
||||
width: 6px; height: 3px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card-stripe">Card with pseudo stripe</div>
|
||||
<div class="row-stripe">Row with right stripe</div>
|
||||
<div class="col">One</div><div class="col">Two</div>
|
||||
<div class="timeline">Timeline</div>
|
||||
<blockquote>Quoted text</blockquote>
|
||||
<span class="switch"></span>
|
||||
<div class="panel">Panel</div>
|
||||
<h2 class="heading">Heading</h2>
|
||||
</body>
|
||||
</html>
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pulsing status dot fixture</title>
|
||||
<style>
|
||||
:root { --dot: 8px; }
|
||||
|
||||
/* FLAG: tiny circular live dot on an infinite opacity pulse. */
|
||||
.live-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: pulse 2.4s ease-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* FLAG: box-shadow ripple variant, var-sized, pill radius. */
|
||||
.status .dot {
|
||||
width: var(--dot); height: var(--dot);
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
animation: ripple 1.8s linear infinite;
|
||||
}
|
||||
@keyframes ripple {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
100% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
/* FLAG: longhand animation-name + iteration-count. */
|
||||
.beacon {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation-name: pulse;
|
||||
animation-duration: 2s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/* PASS: rotation-only spinner — same size class, must never flag. */
|
||||
.spinner {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* PASS: named pulse but the local keyframes only rotate. */
|
||||
.fake-pulse {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse-ring 1s linear infinite;
|
||||
}
|
||||
@keyframes pulse-ring { to { transform: rotate(180deg); } }
|
||||
|
||||
/* PASS: large breathing card — not a dot. */
|
||||
.breathing-card {
|
||||
width: 240px; height: 120px;
|
||||
border-radius: 16px;
|
||||
animation: pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* PASS: finite attention pulse (no infinite). */
|
||||
.nudge {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 0.6s ease-out 3;
|
||||
}
|
||||
|
||||
/* PASS: square badge, not circular. */
|
||||
.square-badge {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 2px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<span class="live-dot"></span>
|
||||
<span class="status"><i class="dot"></i> Online</span>
|
||||
<span class="beacon"></span>
|
||||
<span class="spinner"></span>
|
||||
<span class="fake-pulse"></span>
|
||||
<div class="breathing-card">Card</div>
|
||||
<span class="nudge"></span>
|
||||
<span class="square-badge"></span>
|
||||
<!-- FLAG: Tailwind utility variant -->
|
||||
<span class="animate-ping w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
<!-- PASS: Tailwind pulse on a large skeleton block -->
|
||||
<div class="animate-pulse rounded-md w-48 h-6 bg-gray-200"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user