mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Add mechanical pre-scan for typeset and layout (#345)
* Add mechanical pre-scan for typeset and layout commands. Introduce --scope filtering, layout/type rule scopes, DESIGN.md font-size validation, and pre-scan steps in the skill references so agents run detect before LLM judgment. Fixes #149 Co-authored-by: Cursor <cursoragent@cursor.com> * Add isolated sub-agent orchestration for typeset and layout pre-scans. Run the mechanical detector and visual assessment in parallel sub-agents so deterministic findings cannot anchor LLM judgment, matching the critique pattern Paul requested on PR #345. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix: reject bare --scope so detect never scans unscoped by mistake. When --scope had no value, the CLI dropped the flag and ran a full scan instead of failing, which could silently use the wrong rule set during typeset/layout pre-scans. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix: require both typeset and layout assessments in sub-agents. Close a loophole where agents ran only the mechanical pre-scan inline by interpreting "running both" as permitting one inline assessment. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Abdul Wahab
Cursor
parent
c11cc7b58c
commit
f40e2f8f0a
@@ -1,6 +1,6 @@
|
||||
# Impeccable
|
||||
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 45 deterministic detector rules for AI-generated frontend design.
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 46 deterministic detector rules for AI-generated frontend design.
|
||||
|
||||
> **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
|
||||
|
||||
@@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th
|
||||
Impeccable adds:
|
||||
- **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components.
|
||||
- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more.
|
||||
- **45 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
- **46 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
|
||||
## What's Included
|
||||
|
||||
@@ -357,7 +357,7 @@ npx impeccable ignores add-file "src/legacy/**"
|
||||
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
```
|
||||
|
||||
The detector catches 45 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
|
||||
The detector catches 46 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
|
||||
|
||||
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Impeccable CLI
|
||||
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 45 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 46 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -56,7 +56,7 @@ npx impeccable detect --fast src/
|
||||
|
||||
**Quality**: tiny body text, cramped padding, long line lengths, small touch targets
|
||||
|
||||
45 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
|
||||
46 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
|
||||
|
||||
## Exit Codes
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
import { detectText } from '../engines/regex/detect-text.mjs';
|
||||
@@ -93,6 +94,8 @@ Options:
|
||||
--quiet In text mode, only print the final findings count
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--scope <name> Only report rules in the given design domain
|
||||
(type, layout). Comma-separated.
|
||||
--no-config Do not apply project config, detector ignores, inline
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
@@ -151,6 +154,33 @@ async function detectCli() {
|
||||
const providers = [];
|
||||
if (args.includes('--gpt')) providers.push('gpt');
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const scopes = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
|
||||
const inline = args[i].startsWith('--scope=');
|
||||
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
|
||||
const parsed = (value && !value.startsWith('--'))
|
||||
? value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
|
||||
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
|
||||
if (parsed.length === 0) {
|
||||
process.stderr.write(
|
||||
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
scopes.push(...parsed);
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
|
||||
if (unknownScopes.length > 0) {
|
||||
process.stderr.write(
|
||||
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
@@ -276,6 +306,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
|
||||
@@ -9,6 +9,8 @@ const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
|
||||
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
|
||||
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
|
||||
@@ -16,6 +18,9 @@ const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi;
|
||||
const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi;
|
||||
const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g;
|
||||
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
@@ -283,6 +288,18 @@ function addTypographyFonts(out, typography) {
|
||||
}
|
||||
}
|
||||
|
||||
function addTypographySizes(out, typography) {
|
||||
if (!typography || typeof typography !== 'object') return;
|
||||
for (const role of Object.values(typography)) {
|
||||
if (!role || typeof role !== 'object') continue;
|
||||
const raw = String(role.fontSize ?? '').trim().toLowerCase();
|
||||
if (!FONT_SIZE_LITERAL_RE.test(raw)) continue;
|
||||
const px = resolveLengthPx(raw, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) continue;
|
||||
out.allowedFontSizes.push({ value: raw, px });
|
||||
}
|
||||
}
|
||||
|
||||
function addRoundedScale(out, rounded) {
|
||||
if (!rounded || typeof rounded !== 'object') return;
|
||||
for (const [rawName, value] of Object.entries(rounded)) {
|
||||
@@ -340,10 +357,12 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedFonts: new Set(),
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
addTypographyFonts(out, frontmatter.typography);
|
||||
addTypographySizes(out, frontmatter.typography);
|
||||
addColorObject(out, frontmatter.colors);
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
@@ -352,6 +371,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
out.hasRadii = out.allowedRadii.length > 0;
|
||||
out.hasFontSizes = out.allowedFontSizes.length > 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -418,6 +438,17 @@ function isAllowedRadiusRaw(raw, designSystem) {
|
||||
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
|
||||
}
|
||||
|
||||
function isAllowedFontSizeRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasFontSizes) return true;
|
||||
const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, '');
|
||||
if (!FONT_SIZE_LITERAL_RE.test(text)) return true;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) return true;
|
||||
return designSystem.allowedFontSizes.some(
|
||||
entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX,
|
||||
);
|
||||
}
|
||||
|
||||
function lineLooksCommented(line) {
|
||||
const trimmed = String(line || '').trim();
|
||||
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
|
||||
@@ -509,6 +540,18 @@ function checkRadiusValue(value, filePath, line, designSystem, context) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkFontSizeValue(value, filePath, line, designSystem, context) {
|
||||
const token = String(value || '').trim();
|
||||
if (isAllowedFontSizeRaw(token, designSystem)) return [];
|
||||
return [makeDesignFinding(
|
||||
'design-system-font-size',
|
||||
filePath,
|
||||
`${context}: ${token} is off the DESIGN.md type ramp`,
|
||||
line,
|
||||
{ ignoreValue: token },
|
||||
)];
|
||||
}
|
||||
|
||||
function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
const designSystem = options.designSystem;
|
||||
if (!designSystem?.present) return [];
|
||||
@@ -567,6 +610,18 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasFontSizes) {
|
||||
for (const match of line.matchAll(FONT_SIZE_DECL_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'font-size'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_SIZE_JS_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'fontSize'));
|
||||
}
|
||||
for (const match of line.matchAll(TAILWIND_FONT_SIZE_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'text-[…] class'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeDesignFindings(findings);
|
||||
@@ -581,6 +636,8 @@ function sampleText(el) {
|
||||
return text ? ` "${text.slice(0, 40)}"` : '';
|
||||
}
|
||||
|
||||
// Font-size design-system checks are source-scan-only (see checkSourceDesignSystem).
|
||||
// Computed font-size cascades and clamp() ramps resolve to off-ramp px in the browser.
|
||||
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
|
||||
if (!designSystem?.present) return [];
|
||||
const findings = [];
|
||||
@@ -698,6 +755,12 @@ function canonicalDesignFindingKey(item) {
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:radius:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-font-size') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:font-size:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:font-size:${label}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -744,6 +807,7 @@ export {
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
|
||||
@@ -123,6 +123,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
@@ -132,6 +133,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font for everything',
|
||||
description:
|
||||
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
||||
@@ -141,6 +143,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -177,6 +180,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -186,6 +190,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -213,6 +218,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -222,6 +228,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -231,6 +238,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -240,6 +248,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
@@ -250,6 +259,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
@@ -287,6 +297,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -296,6 +307,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -341,6 +353,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -350,6 +363,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -359,6 +373,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -366,6 +381,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -373,6 +389,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
@@ -380,6 +397,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -387,6 +405,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
@@ -394,6 +413,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -403,6 +423,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -410,6 +431,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
@@ -419,6 +441,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
@@ -428,6 +451,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
@@ -454,6 +478,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
@@ -30,6 +31,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font for everything',
|
||||
description:
|
||||
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
||||
@@ -39,6 +41,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -75,6 +78,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -84,6 +88,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -111,6 +116,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -120,6 +126,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -129,6 +136,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -138,6 +146,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
@@ -148,6 +157,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
@@ -185,6 +195,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -194,6 +205,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -239,6 +251,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -248,6 +261,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -257,6 +271,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -264,6 +279,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -271,6 +287,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
@@ -278,6 +295,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -285,6 +303,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
@@ -292,6 +311,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -301,6 +321,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -308,6 +329,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
@@ -317,6 +339,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
@@ -326,6 +349,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
@@ -352,6 +376,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -448,12 +483,32 @@ function filterByProviders(findings, providers = []) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
|
||||
// CLI --scope flag to narrow output to one design domain.
|
||||
const RULE_SCOPES = new Set(
|
||||
ANTIPATTERNS.flatMap(rule => rule.scopes || []),
|
||||
);
|
||||
|
||||
// Keep only findings whose rule declares at least one of the requested
|
||||
// scopes. An empty scope list means no filtering (default CLI behavior).
|
||||
function filterByScopes(findings, scopes = []) {
|
||||
if (!scopes || scopes.length === 0) return findings;
|
||||
const enabled = new Set(scopes);
|
||||
return findings.filter(f => {
|
||||
const rule = getAntipattern(f.antipattern);
|
||||
return (rule?.scopes || []).some(scope => enabled.has(scope));
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
GATED_PROVIDERS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
filterByProviders,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ That makes CI usage straightforward: fail the job on `2`, then decide whether to
|
||||
|
||||
## DESIGN.md awareness
|
||||
|
||||
When a local `DESIGN.md` exists, `detect` loads it by default and enables design-system checks for fonts, literal colors, and border radii. The generated `.impeccable/design.json` sidecar gives those checks richer token and ramp data.
|
||||
When a local `DESIGN.md` exists, `detect` loads it by default and enables design-system checks for fonts, literal colors, border radii, and literal font sizes documented in the typography ramp. The generated `.impeccable/design.json` sidecar gives those checks richer token and ramp data.
|
||||
|
||||
If the design file is stale, refresh it:
|
||||
|
||||
@@ -68,6 +68,13 @@ If you need one scan without design-system checks:
|
||||
npx impeccable detect --no-design-system src/
|
||||
```
|
||||
|
||||
To narrow a scan to one design domain (for example before a typeset or layout pass):
|
||||
|
||||
```bash
|
||||
npx impeccable detect --scope type src/
|
||||
npx impeccable detect --scope layout src/
|
||||
```
|
||||
|
||||
## Managing intentional findings
|
||||
|
||||
Detector ignores are shared with the design hook:
|
||||
|
||||
@@ -521,7 +521,7 @@ import '../styles/testimonials.css';
|
||||
<article class="ks-bento-tile ks-bento-tile--span-6" id="why-ci">
|
||||
<span class="ks-bento-num" data-color="patina">06</span>
|
||||
<h3 class="why-panel-title">Block slop before it ships.</h3>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 45 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 46 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<div class="why-visual why-visual--ci">
|
||||
<div class="why-ci-window">
|
||||
<div class="why-ci-header">
|
||||
@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
|
||||
</li>
|
||||
<li>
|
||||
<strong>CLI for CI</strong>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 45 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 46 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<a href="https://www.npmjs.com/package/impeccable" target="_blank" rel="noopener">View on npm →</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -12,9 +12,29 @@ Native (`ios` / `android` / `adaptive`): structure follows the Layout section of
|
||||
|
||||
---
|
||||
|
||||
## Two isolated assessments (required)
|
||||
|
||||
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the layout assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, documented spacing scale when present, and its instructions below); do not assume it can read this file.
|
||||
|
||||
**Sub-agent A (layout assessment)**: give it the full [Assess Current Layout](#assess-current-layout) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
|
||||
|
||||
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to layout:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect.mjs --json --scope layout [target files or dirs]
|
||||
```
|
||||
|
||||
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The detector abstains on arbitrary Tailwind spacing (`gap-[13px]`, `p-[7px]`) and ad-hoc `z-index` stacks, so when the project documents a spacing scale, also grep `gap-\[`, `p[trblxy]?-\[`, `m[trblxy]?-\[`, `z-\[` and judge those hits against it. Return the findings JSON plus the grep verdicts.
|
||||
|
||||
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
|
||||
|
||||
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a monotone grid with uniform spacing passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Layout
|
||||
|
||||
Analyze what's weak about the current spatial design:
|
||||
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak about the current spatial design:
|
||||
|
||||
1. **Spacing**:
|
||||
- Is spacing consistent or arbitrary? (Random padding/margin values)
|
||||
@@ -140,6 +160,8 @@ Create a systematic plan:
|
||||
- **Consistency**: Is the spacing system applied uniformly?
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
|
||||
|
||||
When the rhythm and hierarchy land, hand off to `{{command_prefix}}impeccable polish` for the final pass.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
@@ -10,9 +10,29 @@ Product: system fonts and familiar sans stacks are legitimate here. One well-tun
|
||||
|
||||
---
|
||||
|
||||
## Two isolated assessments (required)
|
||||
|
||||
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the typography assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, **DESIGN.md** content when present, and its instructions below); do not assume it can read this file.
|
||||
|
||||
**Sub-agent A (typography assessment)**: give it the full [Assess Current Typography](#assess-current-typography) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
|
||||
|
||||
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to type:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect.mjs --json --scope type [target files or dirs]
|
||||
```
|
||||
|
||||
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The scan checks literal font sizes against the **DESIGN.md** ramp but abstains on `em`, `%`, `clamp()`, and line-heights, so also grep `font-size\s*:`, `fontSize`, `text-\[`, `leading-\[` and judge those hits against the spec. Return the findings JSON plus the grep verdicts.
|
||||
|
||||
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
|
||||
|
||||
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a generic font stack at a flat scale passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Typography
|
||||
|
||||
Analyze what's weak or generic about the current type:
|
||||
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak or generic about the current type:
|
||||
|
||||
1. **Font choices**:
|
||||
- Are we using invisible defaults? (Inter, Roboto, Arial, Open Sans, system defaults)
|
||||
@@ -109,6 +129,8 @@ Build a clear type scale:
|
||||
- **Performance**: Are web fonts loading efficiently without layout shift?
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
|
||||
|
||||
When the type carries the hierarchy on its own, hand off to `{{command_prefix}}impeccable polish` for the final pass.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
isAllowedColorRaw,
|
||||
isAllowedFont,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
loadDesignSystemForCwd,
|
||||
normalizeDesignSystem,
|
||||
} from '../cli/engine/design-system.mjs';
|
||||
@@ -31,8 +32,9 @@ function sampleDesignSystem() {
|
||||
return normalizeDesignSystem({
|
||||
frontmatter: {
|
||||
typography: {
|
||||
display: { fontFamily: 'Avenir Next, Georgia, serif' },
|
||||
body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif' },
|
||||
display: { fontFamily: 'Avenir Next, Georgia, serif', fontSize: 'clamp(2.5rem, 6vw, 4rem)' },
|
||||
body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '16px' },
|
||||
label: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '0.875rem' },
|
||||
},
|
||||
colors: {
|
||||
ink: '#241f1a',
|
||||
@@ -96,6 +98,15 @@ describe('normalizeDesignSystem()', () => {
|
||||
assert.equal(isAllowedRadiusRaw('100px', designSystem), true);
|
||||
assert.equal(isAllowedRadiusRaw('9999px', designSystem), true);
|
||||
assert.equal(isAllowedRadiusRaw('18px', designSystem), false);
|
||||
|
||||
assert.equal(isAllowedFontSizeRaw('16px', designSystem), true);
|
||||
assert.equal(isAllowedFontSizeRaw('1rem', designSystem), true);
|
||||
assert.equal(isAllowedFontSizeRaw('0.875rem', designSystem), true);
|
||||
assert.equal(isAllowedFontSizeRaw('14px', designSystem), true);
|
||||
assert.equal(isAllowedFontSizeRaw('12.5px', designSystem), false);
|
||||
assert.equal(isAllowedFontSizeRaw('1.2em', designSystem), true);
|
||||
assert.equal(isAllowedFontSizeRaw('clamp(1rem, 2vw, 2rem)', designSystem), true);
|
||||
assert.equal(isAllowedFontSizeRaw('var(--text-body)', designSystem), true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,9 +231,50 @@ scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px;';
|
||||
|
||||
assert.deepEqual(
|
||||
findings.map((item) => item.ignoreValue),
|
||||
['#ff00aa', 'Poppins', '#cc00ff'],
|
||||
['10px', '#ff00aa', 'Poppins', '#cc00ff'],
|
||||
);
|
||||
});
|
||||
|
||||
it('reports literal font sizes outside the DESIGN.md type ramp', () => {
|
||||
const designSystem = sampleDesignSystem();
|
||||
const source = `.off-ramp {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
const label = { fontSize: "11px" };
|
||||
const badge = { className: "text-[10px]" };
|
||||
/* font-size: 9px; */
|
||||
.on-ramp {
|
||||
font-size: 1rem;
|
||||
}
|
||||
`;
|
||||
const findings = checkSourceDesignSystem(source, '/tmp/sizes.css', { designSystem });
|
||||
const fontSizeFindings = findings.filter((item) => item.antipattern === 'design-system-font-size');
|
||||
|
||||
assert.equal(fontSizeFindings.length, 3);
|
||||
assert.deepEqual(
|
||||
fontSizeFindings.map((item) => item.ignoreValue),
|
||||
['12.5px', '11px', '10px'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
fontSizeFindings.map((item) => item.line),
|
||||
[2, 4, 5],
|
||||
);
|
||||
});
|
||||
|
||||
it('abstains on font-size checks when DESIGN.md has no literal ramp steps', () => {
|
||||
const designSystem = normalizeDesignSystem({
|
||||
frontmatter: {
|
||||
typography: {
|
||||
display: { fontFamily: 'Avenir Next, Georgia, serif', fontSize: 'clamp(2.5rem, 6vw, 4rem)' },
|
||||
body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: 'clamp(1rem, 2vw, 1.125rem)' },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(designSystem.hasFontSizes, false);
|
||||
|
||||
const findings = checkSourceDesignSystem('.bad { font-size: 12.5px; }', '/tmp/clamp-only.css', { designSystem });
|
||||
assert.equal(findings.some((item) => item.antipattern === 'design-system-font-size'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectStaticDesignSystemFindings()', () => {
|
||||
|
||||
@@ -276,8 +276,9 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
const designSystem = normalizeDesignSystem({
|
||||
frontmatter: {
|
||||
typography: {
|
||||
display: { fontFamily: 'Avenir Next, Georgia, serif' },
|
||||
body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif' },
|
||||
display: { fontFamily: 'Avenir Next, Georgia, serif', fontSize: 'clamp(2.5rem, 6vw, 4rem)' },
|
||||
body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '16px' },
|
||||
label: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '14px' },
|
||||
},
|
||||
colors: {
|
||||
ink: '#241f1a',
|
||||
@@ -315,6 +316,13 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
designFindings.some((r) => r.antipattern === 'design-system-font' && /Google Fonts: Poppins/.test(r.snippet || '')),
|
||||
'expected source-level Google Fonts usage in HTML to be flagged',
|
||||
);
|
||||
assert.ok(
|
||||
designFindings.some((r) => r.antipattern === 'design-system-font-size' && /12\.5px/.test(r.snippet || '')),
|
||||
'expected off-ramp literal font-size to be flagged',
|
||||
);
|
||||
assert.doesNotMatch(snippets, /1rem is off/, 'documented rem step must pass');
|
||||
assert.doesNotMatch(snippets, /1\.2em is off/, 'relative em sizes are abstained on');
|
||||
assert.doesNotMatch(snippets, /16px is off|14px is off/, 'on-ramp sizes must pass');
|
||||
assert.doesNotMatch(snippets, /Undocumented color #ff00aa/, 'source and computed color findings should not duplicate');
|
||||
assert.doesNotMatch(snippets, /font-family: Poppins/, 'source and computed font findings should not duplicate');
|
||||
assert.doesNotMatch(snippets, /border-radius: 18px is outside/, 'source and computed radius findings should not duplicate');
|
||||
@@ -341,6 +349,8 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
}
|
||||
for (const label of [
|
||||
'Pass Display Font',
|
||||
'Pass Rem Font Size',
|
||||
'Pass Relative Font Size',
|
||||
'Pass Generic Font',
|
||||
'Pass Token Color',
|
||||
'Pass Alpha Color',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildImportGraph, resolveImport,
|
||||
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
|
||||
} from '../cli/engine/detect-antipatterns.mjs';
|
||||
import { filterByScopes } from '../cli/engine/registry/antipatterns.mjs';
|
||||
import {
|
||||
checkElementTextOverflowDOM,
|
||||
checkPageTypography,
|
||||
@@ -1054,6 +1055,79 @@ rounded:
|
||||
}
|
||||
});
|
||||
|
||||
test('filterByScopes keeps only findings for the requested design domain', () => {
|
||||
const findings = [
|
||||
{ antipattern: 'flat-type-hierarchy' },
|
||||
{ antipattern: 'nested-cards' },
|
||||
{ antipattern: 'line-length' },
|
||||
];
|
||||
|
||||
expect(filterByScopes(findings, ['type']).map((f) => f.antipattern)).toEqual([
|
||||
'flat-type-hierarchy',
|
||||
'line-length',
|
||||
]);
|
||||
expect(filterByScopes(findings, ['layout']).map((f) => f.antipattern)).toEqual([
|
||||
'nested-cards',
|
||||
'line-length',
|
||||
]);
|
||||
expect(filterByScopes(findings, [])).toEqual(findings);
|
||||
});
|
||||
|
||||
test('--scope filters CLI output to a design domain', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cli-scope-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), `---
|
||||
typography:
|
||||
body:
|
||||
fontFamily: "IBM Plex Sans, Arial, sans-serif"
|
||||
fontSize: "16px"
|
||||
colors:
|
||||
ink: "#241f1a"
|
||||
paper: "#f7f4ee"
|
||||
---
|
||||
|
||||
# Design System
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'index.css'), `
|
||||
.bad {
|
||||
font-family: "IBM Plex Sans", Arial, sans-serif;
|
||||
font-size: 12.5px;
|
||||
color: #ff00aa;
|
||||
}
|
||||
`);
|
||||
|
||||
const full = runIn(dir, '--json', 'index.css');
|
||||
expect(full.code).toBe(2);
|
||||
const fullIds = JSON.parse(full.stdout).map((finding) => finding.antipattern);
|
||||
expect(fullIds).toContain('design-system-font-size');
|
||||
expect(fullIds).toContain('design-system-color');
|
||||
|
||||
const typeOnly = runIn(dir, '--json', '--scope', 'type', 'index.css');
|
||||
const typeIds = JSON.parse(typeOnly.stdout).map((finding) => finding.antipattern);
|
||||
expect(typeIds).toContain('design-system-font-size');
|
||||
expect(typeIds.some((id) => id === 'design-system-color')).toBe(false);
|
||||
|
||||
const badScope = runIn(dir, '--scope', 'bogus', 'index.css');
|
||||
expect(badScope.code).toBe(1);
|
||||
expect(badScope.stderr).toContain('Valid scopes:');
|
||||
|
||||
// A bare --scope must fail instead of silently scanning unscoped.
|
||||
const missingTrailing = runIn(dir, 'index.css', '--scope');
|
||||
expect(missingTrailing.code).toBe(1);
|
||||
expect(missingTrailing.stderr).toContain('--scope requires a value');
|
||||
|
||||
const missingBeforeFlag = runIn(dir, '--scope', '--json', 'index.css');
|
||||
expect(missingBeforeFlag.code).toBe(1);
|
||||
expect(missingBeforeFlag.stderr).toContain('--scope requires a value');
|
||||
|
||||
const emptyInline = runIn(dir, '--scope=', 'index.css');
|
||||
expect(emptyInline.code).toBe(1);
|
||||
expect(emptyInline.stderr).toContain('--scope requires a value');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detector designSystem.enabled=false disables CLI design-system rules', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cli-design-disabled-'));
|
||||
try {
|
||||
|
||||
@@ -38,8 +38,11 @@
|
||||
.flag-background { background-color: rgb(20, 180, 220); }
|
||||
.flag-border { border-color: #00a982; }
|
||||
.flag-radius { border-radius: 18px; }
|
||||
.flag-font-size { font-size: 12.5px; }
|
||||
|
||||
.pass-display-font { font-family: "Avenir Next", Georgia, serif; }
|
||||
.pass-rem-font-size { font-size: 1rem; }
|
||||
.pass-relative-font-size { font-size: 1.2em; }
|
||||
.pass-generic-font { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
.pass-token-color { color: var(--brand-accent); }
|
||||
.pass-alpha-color { color: rgba(184, 66, 46, 0.45); }
|
||||
@@ -61,9 +64,12 @@
|
||||
<div class="case flag-background">Flag Background Cyan</div>
|
||||
<div class="case flag-border">Flag Border Teal</div>
|
||||
<div class="case flag-radius">Flag Radius Eighteen</div>
|
||||
<div class="case flag-font-size">Flag Font Size Twelve Point Five</div>
|
||||
<div class="case" data-font-source="https://fonts.googleapis.com/css2?family=Poppins:wght@400&display=swap">Flag Google Font Source</div>
|
||||
|
||||
<div class="case pass-display-font">Pass Display Font</div>
|
||||
<div class="case pass-rem-font-size">Pass Rem Font Size</div>
|
||||
<div class="case pass-relative-font-size">Pass Relative Font Size</div>
|
||||
<div class="case pass-generic-font">Pass Generic Font</div>
|
||||
<div class="case pass-token-color">Pass Token Color</div>
|
||||
<div class="case pass-alpha-color">Pass Alpha Color</div>
|
||||
|
||||
Reference in New Issue
Block a user