mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
drop "no pure black/white" rule entirely
The rule was contested in the design world and causing more damage than good — pushing every page into the tinted-near-white default which is the cream/sand AI tell we already explicitly ban elsewhere. Vercel, SVKMS, Brutalist sites, et al. use pure black/white successfully; the skill shouldn't second-guess that. Skill markdown deletions: - SKILL.md Color: drop the "Never use #000 or #fff" bullet. - color-and-contrast.md: drop the "Never Use Pure Gray or Pure Black" subsection, the "Never pure black" table-row prescription, and the "Avoid: Using pure black for large areas" bullet. - colorize.md: drop the "NEVER use pure black or pure white for large areas" bullet. - polish.md: drop the "Tinted neutrals: No pure gray or pure black" half of the bullet (the gray-on-color bullet survives). Detector code (cli/engine): - registry/antipatterns.mjs: remove the `pure-black-white` entry. - rules/checks.mjs: remove the three `findings.push({ id: 'pure-black-white', ... })` emit points (inline #000 bg, Tailwind bg-black class, plain-HTML scan path). - engines/regex/detect-text.mjs: remove the two pure-black-white regex rules (CSS `background: #000…` + Tailwind `bg-black`). - detect-antipatterns-browser.js: regenerated via scripts/build-browser-detector.js. Tests: - detect-antipatterns-fixtures.test.mjs: invert the assertion that pure-black-white fires; expect it to NOT fire post-v3.2. Drop the Tailwind bg-black-opacity edge-case test (no longer relevant). - detect-antipatterns.test.js: drop the standalone "detects pure- black-white in styled-components" test and remove pure-black-white from the multi-detector assertions in PricingCard, globals.css, and GlobalStyle.tsx tests. 166 bun tests pass; 24 node fixture tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c9a2e79578
commit
7f28ee0e11
@@ -249,15 +249,6 @@ const ANTIPATTERNS = [
|
||||
},
|
||||
|
||||
// ── Quality: general design and accessibility issues ──
|
||||
{
|
||||
id: 'pure-black-white',
|
||||
category: 'quality',
|
||||
name: 'Pure black background',
|
||||
description:
|
||||
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'pure black (#000)',
|
||||
},
|
||||
{
|
||||
id: 'gray-on-color',
|
||||
category: 'quality',
|
||||
@@ -527,11 +518,6 @@ function checkColors(opts) {
|
||||
}
|
||||
const findings = [];
|
||||
|
||||
// Pure black background (only solid or near-solid, not semi-transparent overlays)
|
||||
if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
|
||||
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
|
||||
}
|
||||
|
||||
if (hasDirectText && textColor && !isEmojiOnly) {
|
||||
// Run background-dependent checks against either a solid bg or, if the
|
||||
// ancestor is a gradient, against every gradient stop (use the worst case).
|
||||
@@ -587,9 +573,6 @@ function checkColors(opts) {
|
||||
// Tailwind class checks
|
||||
if (classList) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
if (/\bbg-black\b(?!\/)/.test(classStr)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
|
||||
}
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
@@ -905,12 +888,6 @@ function checkHtmlPatterns(html) {
|
||||
|
||||
// --- Color ---
|
||||
|
||||
// Pure black background
|
||||
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
|
||||
if (pureBlackBgRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
|
||||
}
|
||||
|
||||
// AI color palette: purple/violet
|
||||
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
||||
if (purpleHexRe.test(html)) {
|
||||
|
||||
@@ -63,10 +63,6 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Mona\+Sans|Geist)\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
|
||||
// --- Pure black background ---
|
||||
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => m[0] },
|
||||
// --- Gradient text ---
|
||||
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
|
||||
test: (m, line) => /gradient/i.test(line),
|
||||
@@ -75,10 +71,6 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
|
||||
test: (m, line) => /\bbg-gradient-to-/i.test(line),
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind pure black background ---
|
||||
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
|
||||
test: () => true,
|
||||
fmt: (m) => m[0] },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
|
||||
@@ -147,15 +147,6 @@ const ANTIPATTERNS = [
|
||||
},
|
||||
|
||||
// ── Quality: general design and accessibility issues ──
|
||||
{
|
||||
id: 'pure-black-white',
|
||||
category: 'quality',
|
||||
name: 'Pure black background',
|
||||
description:
|
||||
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'pure black (#000)',
|
||||
},
|
||||
{
|
||||
id: 'gray-on-color',
|
||||
category: 'quality',
|
||||
|
||||
@@ -78,11 +78,6 @@ function checkColors(opts) {
|
||||
}
|
||||
const findings = [];
|
||||
|
||||
// Pure black background (only solid or near-solid, not semi-transparent overlays)
|
||||
if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
|
||||
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
|
||||
}
|
||||
|
||||
if (hasDirectText && textColor && !isEmojiOnly) {
|
||||
// Run background-dependent checks against either a solid bg or, if the
|
||||
// ancestor is a gradient, against every gradient stop (use the worst case).
|
||||
@@ -138,9 +133,6 @@ function checkColors(opts) {
|
||||
// Tailwind class checks
|
||||
if (classList) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
if (/\bbg-black\b(?!\/)/.test(classStr)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
|
||||
}
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
@@ -456,12 +448,6 @@ function checkHtmlPatterns(html) {
|
||||
|
||||
// --- Color ---
|
||||
|
||||
// Pure black background
|
||||
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
|
||||
if (pureBlackBgRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
|
||||
}
|
||||
|
||||
// AI color palette: purple/violet
|
||||
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
||||
if (purpleHexRe.test(html)) {
|
||||
|
||||
@@ -60,7 +60,6 @@ Apply to every design, both registers. Match implementation complexity to the ae
|
||||
### Color
|
||||
|
||||
- Use OKLCH. Reduce chroma as lightness approaches 0 or 100; high chroma at extremes looks garish.
|
||||
- Never use `#000` or `#fff`. When you use a neutral, tint it slightly toward the brand hue (chroma 0.005–0.01 is enough).
|
||||
- **The cream / sand / beige body bg is the saturated AI default of 2026.** The whole warm-neutral band — OKLCH L 0.84-0.97, C < 0.06, hue 40-100 — reads as cream/sand/paper/parchment regardless of what you call it. Token names like `--paper`, `--cream`, `--sand`, `--bone`, `--flour`, `--linen`, `--parchment`, `--wheat`, `--biscuit`, `--ivory` are tells in themselves. If the brief is "warm, traditional, family-coastal-Italian" or "magazine-warm" or "editorial-restraint", DO NOT translate that into a near-white warm-tinted bg — that's the AI move. Pick: (a) a saturated brand color as the body (terracotta, oxblood, deep ochre, near-black), (b) a true off-white at chroma 0 (or chroma toward the brand's own hue, not toward warmth-by-default), or (c) a darker mid-tone tinted neutral that's clearly the brand's own. "Warmth" in the brand is carried by accent + typography + imagery, not by body bg.
|
||||
- Pick a **color strategy** before picking colors. Four steps on the commitment axis:
|
||||
- **Restrained**: tinted neutrals + one accent ≤10%. Product default; brand minimalism.
|
||||
|
||||
@@ -65,10 +65,6 @@ These commonly fail contrast or cause readability issues:
|
||||
- Yellow text on white (almost always fails)
|
||||
- Thin light text on images (unpredictable contrast)
|
||||
|
||||
### Never Use Pure Gray or Pure Black
|
||||
|
||||
Pure gray (`oklch(50% 0 0)`) and pure black (`#000`) don't exist in nature; real shadows and surfaces always have a color cast. Even a chroma of 0.005-0.01 is enough to feel natural without being obviously tinted. (See tinted neutrals example above.)
|
||||
|
||||
### Testing
|
||||
|
||||
Don't trust your eyes. Use tools:
|
||||
@@ -88,7 +84,7 @@ You can't just swap colors. Dark mode requires different design decisions:
|
||||
| Shadows for depth | Lighter surfaces for depth (no shadows) |
|
||||
| Dark text on light | Light text on dark (reduce font weight) |
|
||||
| Vibrant accents | Desaturate accents slightly |
|
||||
| White backgrounds | Never pure black; use dark gray (oklch 12-18%) |
|
||||
| White backgrounds | Either pure black or a deep surface that fits the brand (a brand-tinted near-black at oklch 12-18% works too) |
|
||||
|
||||
In dark mode, depth comes from surface lightness, not shadow. Build a 3-step surface scale where higher elevations are lighter (e.g. 15% / 20% / 25% lightness). Use the SAME hue and chroma as your brand color (whatever it is for THIS project; do not reach for blue) and only vary the lightness. Reduce body text weight slightly (e.g. 350 instead of 400) because light text on dark reads as heavier than dark text on light.
|
||||
|
||||
@@ -102,4 +98,4 @@ Heavy use of transparency (rgba, hsla) usually means an incomplete palette. Alph
|
||||
|
||||
---
|
||||
|
||||
**Avoid**: Relying on color alone to convey information. Creating palettes without clear roles for each color. Using pure black (#000) for large areas. Skipping color blindness testing (8% of men affected).
|
||||
**Avoid**: Relying on color alone to convey information. Creating palettes without clear roles for each color. Skipping color blindness testing (8% of men affected).
|
||||
|
||||
@@ -124,8 +124,6 @@ Ensure color addition improves rather than overwhelms:
|
||||
- Use every color in the rainbow (choose 2-4 colors beyond neutrals)
|
||||
- Apply color randomly without semantic meaning
|
||||
- Put gray text on colored backgrounds. It looks washed out; use a darker shade of the background color or transparency instead
|
||||
- Use pure gray for neutrals. Add subtle color tint (warm or cool) for depth
|
||||
- Use pure black (`#000`) or pure white (`#fff`) for large areas
|
||||
- Violate WCAG contrast requirements
|
||||
- Use color as the only indicator (accessibility issue)
|
||||
- Make everything colorful (defeats the purpose)
|
||||
|
||||
@@ -91,7 +91,6 @@ Visual polish on a misshapen flow is wasted work. Match the *shape* of the exper
|
||||
- **Theme consistency**: Works in all theme variants
|
||||
- **Color meaning**: Same colors mean same things throughout
|
||||
- **Accessible focus**: Focus indicators visible with sufficient contrast
|
||||
- **Tinted neutrals**: No pure gray or pure black; add subtle color tint (0.01 chroma)
|
||||
- **Gray on color**: Never put gray text on colored backgrounds; use a shade of that color or transparency
|
||||
|
||||
### Interaction States
|
||||
|
||||
@@ -63,16 +63,16 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
|
||||
it('color: flag column triggers all color rules, pass column adds none', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
// All five color rules must fire from the flag column
|
||||
assert.ok(f.some(r => r.antipattern === 'pure-black-white'), 'expected pure-black-white');
|
||||
// pure-black-white was removed from the skill in v3.2; only the remaining rules
|
||||
// are expected to fire from the flag column.
|
||||
assert.ok(f.some(r => r.antipattern === 'gray-on-color'), 'expected gray-on-color');
|
||||
assert.ok(f.some(r => r.antipattern === 'low-contrast'), 'expected low-contrast');
|
||||
assert.ok(f.some(r => r.antipattern === 'gradient-text'), 'expected gradient-text');
|
||||
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
|
||||
assert.equal(
|
||||
f.some(r => r.antipattern === 'pure-black-white' && /#ffffff|#fff/i.test(r.snippet || '')),
|
||||
f.some(r => r.antipattern === 'pure-black-white'),
|
||||
false,
|
||||
'pure white surfaces with dark text should remain allowed',
|
||||
'pure-black-white detector was removed in v3.2',
|
||||
);
|
||||
// Gradient-bg + gray text case (added with the gradient-fix patch)
|
||||
assert.ok(
|
||||
@@ -100,26 +100,6 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('color: Tailwind bg-black/N opacity modifiers are not flagged as pure-black-white', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
// The pass column has bg-black/3, hover:bg-black/5, bg-black/50 — none are pure black.
|
||||
// Only the flag column's literal bg-black class should trigger pure-black-white.
|
||||
const pureBlackFindings = f.filter(r => r.antipattern === 'pure-black-white');
|
||||
const opacityFalsePositives = pureBlackFindings.filter(r =>
|
||||
(r.snippet || '').includes('bg-black') &&
|
||||
f.some(() => true) // check that bg-black/N class triggers are absent
|
||||
);
|
||||
// There should be exactly the flag-column hits (bg-black class + #000000 inline)
|
||||
// and zero from the pass-column opacity variants.
|
||||
// The pass-column elements have data-test attributes starting with "bg-black-"
|
||||
// The Tailwind class check produces snippet "bg-black" — count those.
|
||||
const twSnippets = pureBlackFindings.filter(r => (r.snippet || '') === 'bg-black');
|
||||
assert.equal(
|
||||
twSnippets.length, 1,
|
||||
`expected exactly 1 Tailwind bg-black finding (flag column only), got ${twSnippets.length}: ${twSnippets.map(r => r.snippet).join('; ')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('color: styled <a> and <button> with their own background get contrast checks', async () => {
|
||||
// SAFE_TAGS skips <a> and <button> by default to avoid noise on inline links
|
||||
// (text links inside paragraphs). When these elements are styled as buttons
|
||||
|
||||
@@ -979,12 +979,6 @@ describe('detectText -- CSS-in-JS', () => {
|
||||
expect(f.some(r => r.antipattern === 'gradient-text')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects pure-black-white in styled-components', () => {
|
||||
const tsx = "const Dark = styled.section`\n background-color: #000000;\n`;";
|
||||
const f = detectText(tsx, 'Dark.tsx');
|
||||
expect(f.some(r => r.antipattern === 'pure-black-white')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not false-positive on clean CSS-in-JS', () => {
|
||||
const tsx = "const Card = styled.div`\n border-radius: 12px;\n padding: 24px;\n`;";
|
||||
const f = detectText(tsx, 'Card.tsx');
|
||||
@@ -1064,7 +1058,7 @@ describe('CLI -- Next.js + Tailwind project', () => {
|
||||
const result = run(dir);
|
||||
stderr = result.stderr;
|
||||
expect(result.code).toBe(2);
|
||||
for (const ap of ['side-tab', 'gradient-text', 'ai-color-palette', 'overused-font', 'bounce-easing', 'pure-black-white']) {
|
||||
for (const ap of ['side-tab', 'gradient-text', 'ai-color-palette', 'overused-font', 'bounce-easing']) {
|
||||
expect(stderr).toContain(ap);
|
||||
}
|
||||
});
|
||||
@@ -1079,10 +1073,8 @@ describe('CLI -- Next.js + Tailwind project', () => {
|
||||
expect(stderr).toContain('animate-bounce');
|
||||
});
|
||||
|
||||
test('PricingCard: pure-black-white + gradient-text + ai-color-palette', () => {
|
||||
test('PricingCard: gradient-text + ai-color-palette', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'PricingCard.tsx'));
|
||||
expect(stderr).toContain('pure-black-white');
|
||||
expect(stderr).toContain('bg-black');
|
||||
expect(stderr).toContain('gradient-text');
|
||||
expect(stderr).toContain('bg-clip-text');
|
||||
expect(stderr).toContain('ai-color-palette');
|
||||
@@ -1125,7 +1117,7 @@ describe('CLI -- Next.js + CSS Modules project', () => {
|
||||
test('finds all expected anti-pattern types', () => {
|
||||
const { code, stderr } = run(dir);
|
||||
expect(code).toBe(2);
|
||||
for (const ap of ['side-tab', 'overused-font', 'pure-black-white', 'layout-transition', 'gradient-text']) {
|
||||
for (const ap of ['side-tab', 'overused-font', 'layout-transition', 'gradient-text']) {
|
||||
expect(stderr).toContain(ap);
|
||||
}
|
||||
});
|
||||
@@ -1146,12 +1138,10 @@ describe('CLI -- Next.js + CSS Modules project', () => {
|
||||
expect(stderr).toContain('border-right: 3px solid');
|
||||
});
|
||||
|
||||
test('globals.css: overused Roboto + pure-black-white', () => {
|
||||
test('globals.css: overused Roboto', () => {
|
||||
const { stderr } = run(path.join(dir, 'app', 'globals.css'));
|
||||
expect(stderr).toContain('overused-font');
|
||||
expect(stderr).toContain('Roboto');
|
||||
expect(stderr).toContain('pure-black-white');
|
||||
expect(stderr).toContain('#000000');
|
||||
});
|
||||
|
||||
test('page.module.css: gradient-text across lines', () => {
|
||||
@@ -1179,7 +1169,7 @@ describe('CLI -- Next.js + CSS-in-JS (styled-components) project', () => {
|
||||
test('finds all expected anti-pattern types', () => {
|
||||
const { code, stderr } = run(dir);
|
||||
expect(code).toBe(2);
|
||||
for (const ap of ['side-tab', 'gradient-text', 'overused-font', 'bounce-easing', 'pure-black-white', 'layout-transition']) {
|
||||
for (const ap of ['side-tab', 'gradient-text', 'overused-font', 'bounce-easing', 'layout-transition']) {
|
||||
expect(stderr).toContain(ap);
|
||||
}
|
||||
});
|
||||
@@ -1202,12 +1192,10 @@ describe('CLI -- Next.js + CSS-in-JS (styled-components) project', () => {
|
||||
expect(stderr).toContain('Montserrat');
|
||||
});
|
||||
|
||||
test('GlobalStyle.tsx: overused Inter + pure-black-white', () => {
|
||||
test('GlobalStyle.tsx: overused Inter', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'GlobalStyle.tsx'));
|
||||
expect(stderr).toContain('overused-font');
|
||||
expect(stderr).toContain('Inter');
|
||||
expect(stderr).toContain('pure-black-white');
|
||||
expect(stderr).toContain('#000000');
|
||||
});
|
||||
|
||||
test('Testimonials.tsx: side-tab + gradient-text in styled blockquote', () => {
|
||||
|
||||
Reference in New Issue
Block a user