diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md index 9b7ca8f70..62c6de961 100644 --- a/.agents/skills/impeccable/SKILL.md +++ b/.agents/skills/impeccable/SKILL.md @@ -10,9 +10,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -58,6 +58,7 @@ Two hard typographic ceilings you currently miss: - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -80,7 +81,7 @@ Two hard typographic ceilings you currently miss: #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.claude/skills/impeccable/SKILL.md b/.claude/skills/impeccable/SKILL.md index 462ddb569..0ea9eeae6 100644 --- a/.claude/skills/impeccable/SKILL.md +++ b/.claude/skills/impeccable/SKILL.md @@ -16,9 +16,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -60,6 +60,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -82,7 +83,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.cursor/skills/impeccable/SKILL.md b/.cursor/skills/impeccable/SKILL.md index e4e287ace..05b4b5542 100644 --- a/.cursor/skills/impeccable/SKILL.md +++ b/.cursor/skills/impeccable/SKILL.md @@ -12,9 +12,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -56,6 +56,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -78,7 +79,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.gemini/skills/impeccable/SKILL.md b/.gemini/skills/impeccable/SKILL.md index f536fd8c6..8725bc082 100644 --- a/.gemini/skills/impeccable/SKILL.md +++ b/.gemini/skills/impeccable/SKILL.md @@ -11,9 +11,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -55,6 +55,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -79,7 +80,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.github/skills/impeccable/SKILL.md b/.github/skills/impeccable/SKILL.md index 6a5767df0..8d1dea4ef 100644 --- a/.github/skills/impeccable/SKILL.md +++ b/.github/skills/impeccable/SKILL.md @@ -14,9 +14,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -58,6 +58,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -80,7 +81,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.kiro/skills/impeccable/SKILL.md b/.kiro/skills/impeccable/SKILL.md index 392cfcff2..d550a33d2 100644 --- a/.kiro/skills/impeccable/SKILL.md +++ b/.kiro/skills/impeccable/SKILL.md @@ -12,9 +12,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .kiro/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -56,6 +56,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -78,7 +79,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.opencode/skills/impeccable/SKILL.md b/.opencode/skills/impeccable/SKILL.md index 6aacd96dd..2675f8b28 100644 --- a/.opencode/skills/impeccable/SKILL.md +++ b/.opencode/skills/impeccable/SKILL.md @@ -16,9 +16,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .opencode/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -60,6 +60,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -82,7 +83,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.pi/skills/impeccable/SKILL.md b/.pi/skills/impeccable/SKILL.md index 468d91dde..2c8493e23 100644 --- a/.pi/skills/impeccable/SKILL.md +++ b/.pi/skills/impeccable/SKILL.md @@ -14,9 +14,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .pi/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -58,6 +58,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -80,7 +81,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.qoder/skills/impeccable/SKILL.md b/.qoder/skills/impeccable/SKILL.md index 71eeb611f..c4cd0f872 100644 --- a/.qoder/skills/impeccable/SKILL.md +++ b/.qoder/skills/impeccable/SKILL.md @@ -16,9 +16,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .qoder/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -60,6 +60,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -82,7 +83,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.rovodev/skills/impeccable/SKILL.md b/.rovodev/skills/impeccable/SKILL.md index 86f31ad6d..953e3076f 100644 --- a/.rovodev/skills/impeccable/SKILL.md +++ b/.rovodev/skills/impeccable/SKILL.md @@ -16,9 +16,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .rovodev/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -60,6 +60,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -82,7 +83,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.trae-cn/skills/impeccable/SKILL.md b/.trae-cn/skills/impeccable/SKILL.md index 766227ee7..22fa68331 100644 --- a/.trae-cn/skills/impeccable/SKILL.md +++ b/.trae-cn/skills/impeccable/SKILL.md @@ -14,9 +14,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .trae-cn/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -58,6 +58,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -80,7 +81,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.trae/skills/impeccable/SKILL.md b/.trae/skills/impeccable/SKILL.md index 2d214b5e1..45ab3f8c6 100644 --- a/.trae/skills/impeccable/SKILL.md +++ b/.trae/skills/impeccable/SKILL.md @@ -14,9 +14,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .trae/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -58,6 +58,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -80,7 +81,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/cli/engine/engines/static-html/detect-html.mjs +++ b/cli/engine/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/plugin/skills/impeccable/SKILL.md b/plugin/skills/impeccable/SKILL.md index 462ddb569..0ea9eeae6 100644 --- a/plugin/skills/impeccable/SKILL.md +++ b/plugin/skills/impeccable/SKILL.md @@ -16,9 +16,9 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: 1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/teach.md` before doing anything else.** -2. Familiarize yourself with any existing design system, conventions, and components in the code. Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. -3. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. -4. If the user invoked a sub-command (`craft`, `shape`, `audit`, ...), load its reference too. +2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. +3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. +4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. ## Design guidance @@ -60,6 +60,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. - Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc) - Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition. - Sibling stagger on a list (cards, list items) is legitimate. Whole-section fade-on-scroll on every scrolled section is not a list; it's the saturated AI motion reflex. +- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank. - Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth. #### Interaction @@ -82,7 +83,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points. #### Color & Theme - Use OKLCH. -- Don't default to a cream / sand / beige body bg (white is fine) - this is a common AI trope in 2026. +- **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. - Tinted neutrals: add 0.005–0.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move. - When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does. - Pick a **color strategy** before picking colors. Four steps on the commitment axis: diff --git a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index cb61ea1f4..8ba54e4a5 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -247,6 +247,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── { diff --git a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index f46d24607..a9f0be198 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); const hasBorderRadius = (line) => /border-radius/i.test(line); const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); +/** Strip HTML to plain text — drops script/style/comments/tags so + * content-text analyzers don't false-positive on code or CSS. */ +function stripHtmlToText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -116,6 +127,14 @@ const REGEX_MATCHERS = [ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, ]; const REGEX_ANALYZERS = [ @@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [ if (total < 5 || centered / total <= 0.7) return []; return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)]; }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, // Dark glow (page-level: dark bg + colored box-shadow with blur) (content, filePath) => { // Check if page has a dark background @@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, return findings; } +/** Page-level analyzers that scan rendered text content (em-dash use, + * buzzword phrases, numbered section markers, aphoristic cadence). + * These are detector-agnostic — they work on any HTML/text source + * and don't need a parsed DOM. Exported so detectHtml can call them + * for `.html` files (which otherwise skip the regex engine). */ +const TEXT_CONTENT_ANALYZER_IDS = [ + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', +]; + +function runTextContentAnalyzers(content, filePath, options = {}) { + const profile = options?.profile; + if (!isFullPage(content)) return []; + // The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS. + const findings = []; + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { + const analyzer = REGEX_ANALYZERS[4 + i]; + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; + findings.push(...profileFindings(profile, { + engine: 'regex', + phase: 'text-content', + ruleId, + target: filePath, + }, () => analyzer(content, filePath))); + } + return findings; +} + function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; @@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) { 'flat-type-hierarchy', 'monotonous-spacing', 'everything-centered', + 'em-dash-overuse', + 'marketing-buzzword', + 'numbered-section-markers', + 'aphoristic-cadence', 'dark-glow', ]; for (let i = 0; i < REGEX_ANALYZERS.length; i++) { @@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) { export { REGEX_MATCHERS, REGEX_ANALYZERS, + TEXT_CONTENT_ANALYZER_IDS, extractStyleBlocks, extractCSSinJS, runRegexMatchers, + runTextContentAnalyzers, detectText, }; diff --git a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 42056a629..f588ef1bc 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -21,7 +21,7 @@ import { resolveBackground, resolveBorderRadiusPx, } from '../../rules/checks.mjs'; -import { detectText } from '../regex/detect-text.mjs'; +import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; import { StaticDocument, buildStaticStyleMap, @@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) { return findings; } +function checkElementBrokenImage(el) { + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; + // Missing src attribute entirely + if (src === undefined || src === null) { + return [{ id: 'broken-image', snippet: ' with no src attribute' }]; + } + const trimmed = String(src).trim(); + // Empty or placeholder-only src values + if (trimmed === '' || trimmed === '#') { + return [{ id: 'broken-image', snippet: `` }]; + } + return []; +} + const STATIC_ELEMENT_RULES = [ { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, @@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [ { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, ]; @@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) { ))) { findings.push(finding(f.id, filePath, f.snippet)); } + // Text-content analyzers (em-dash overuse, marketing buzzwords, + // numbered section markers, aphoristic cadence) live in the regex + // engine. Call them from here so .html files get the same coverage + // as .css/.tsx files. These are scoped to text content only and + // don't overlap with static-html's element/page rules. + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { + findings.push(finding(f.antipattern, filePath, f.snippet)); + } } return findings; diff --git a/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs index fa44a71dc..e51f58031 100644 --- a/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -145,6 +145,52 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-markers', + category: 'slop', + severity: 'advisory', + name: 'Numbered section markers (01 / 02 / 03)', + description: + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, + { + id: 'em-dash-overuse', + category: 'slop', + name: 'Em-dash overuse', + description: + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', + skillSection: 'Copy', + skillGuideline: 'no em dashes', + }, + { + id: 'marketing-buzzword', + category: 'slop', + name: 'Marketing buzzword', + description: + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', + skillSection: 'Copy', + skillGuideline: 'marketing buzzwords', + }, + { + id: 'aphoristic-cadence', + category: 'slop', + name: 'Aphoristic-cadence copy', + description: + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', + skillSection: 'Copy', + skillGuideline: 'aphoristic cadence', + }, + { + id: 'broken-image', + category: 'quality', + name: 'Broken or placeholder image', + description: + ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', + skillSection: 'Imagery', + skillGuideline: 'broken image references', + }, // ── Quality: general design and accessibility issues ── {