From 7f28ee0e11e9e7004d0c549c6b8731480ae044bc Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 20 May 2026 10:26:01 -0700 Subject: [PATCH] drop "no pure black/white" rule entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cli/engine/detect-antipatterns-browser.js | 23 ----------------- cli/engine/engines/regex/detect-text.mjs | 8 ------ cli/engine/registry/antipatterns.mjs | 9 ------- cli/engine/rules/checks.mjs | 14 ----------- skill/SKILL.md | 1 - skill/reference/color-and-contrast.md | 8 ++---- skill/reference/colorize.md | 2 -- skill/reference/polish.md | 1 - tests/detect-antipatterns-fixtures.test.mjs | 28 +++------------------ tests/detect-antipatterns.test.js | 24 +++++------------- 10 files changed, 12 insertions(+), 106 deletions(-) diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 1afbe3f12..cb61ea1f4 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -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)) { diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 4197c161b..f46d24607 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -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), diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index c5de44b2e..fa44a71dc 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -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', diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 677080417..17b5099da 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -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)) { diff --git a/skill/SKILL.md b/skill/SKILL.md index 8946e1322..035c9ff0c 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -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. diff --git a/skill/reference/color-and-contrast.md b/skill/reference/color-and-contrast.md index 110c2ee47..b49ac8223 100644 --- a/skill/reference/color-and-contrast.md +++ b/skill/reference/color-and-contrast.md @@ -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). diff --git a/skill/reference/colorize.md b/skill/reference/colorize.md index 1ecc9530c..680bb468d 100644 --- a/skill/reference/colorize.md +++ b/skill/reference/colorize.md @@ -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) diff --git a/skill/reference/polish.md b/skill/reference/polish.md index 7274c89f6..bc26cfc02 100644 --- a/skill/reference/polish.md +++ b/skill/reference/polish.md @@ -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 diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 853e43e38..78b806ad0 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -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 and