mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Merge remote-tracking branch 'upstream/main' into fix/detect-system-chrome-gpu-window
# Conflicts: # scripts/test-suites.mjs
This commit is contained in:
+221
-28
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs';
|
||||
import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
|
||||
import { createInterface, emitKeypressEvents } from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -65,11 +65,23 @@ const PROVIDER_DISPLAY = {
|
||||
};
|
||||
const PROVIDER_INPUT_ORDER = ['claude', 'codex', 'cursor', 'gemini', 'github', 'grok', 'kiro', 'opencode', 'pi', 'qoder', 'trae', 'trae-cn', 'rovo-dev', 'vibe'];
|
||||
|
||||
// Providers whose GLOBAL (home) skills dir is not `<provider>/skills`.
|
||||
// Pi discovers global skills from ~/.pi/agent/skills/; project scope
|
||||
// stays .pi/skills/. See issue #327.
|
||||
// OpenCode reads global skills from its config directory, not ~/.opencode:
|
||||
// $OPENCODE_CONFIG_DIR, else $XDG_CONFIG_HOME/opencode, else
|
||||
// ~/.config/opencode. Writing to ~/.opencode/skills produced an install
|
||||
// `opencode debug skill` never listed. See issue #406.
|
||||
function opencodeGlobalConfigDir(home) {
|
||||
if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR;
|
||||
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode');
|
||||
return join(home, '.config', 'opencode');
|
||||
}
|
||||
|
||||
// Providers whose GLOBAL (home) skills dir is not `<provider>/skills`,
|
||||
// as a function of the home dir. Pi discovers global skills from
|
||||
// ~/.pi/agent/skills/ (issue #327); OpenCode from its config dir (issue
|
||||
// #406). Project scope stays `<provider>/skills` for both.
|
||||
const HOME_SKILLS_DIR_OVERRIDES = {
|
||||
'.pi': join('.pi', 'agent', 'skills'),
|
||||
'.pi': (home) => join(home, '.pi', 'agent', 'skills'),
|
||||
'.opencode': (home) => join(opencodeGlobalConfigDir(home), 'skills'),
|
||||
};
|
||||
|
||||
// When a project has no harness folder yet, infer the target from globally
|
||||
@@ -83,6 +95,9 @@ const GLOBAL_HARNESS_HINTS = [
|
||||
{ home: '.grok', provider: '.grok' },
|
||||
{ home: '.kiro', provider: '.kiro' },
|
||||
{ home: '.opencode', provider: '.opencode' },
|
||||
// OpenCode's real global config dir (issue #406); the ~/.opencode entry
|
||||
// above keeps recognizing machines that only have the legacy dir.
|
||||
{ resolve: opencodeGlobalConfigDir, provider: '.opencode' },
|
||||
{ home: '.pi', provider: '.pi' },
|
||||
{ home: '.qoder', provider: '.qoder' },
|
||||
{ home: '.rovodev', provider: '.rovodev' },
|
||||
@@ -134,7 +149,8 @@ const PROVIDER_HOOK_ARTIFACTS = {
|
||||
};
|
||||
|
||||
function userProviderSkillsDir(home, provider) {
|
||||
if (HOME_SKILLS_DIR_OVERRIDES[provider]) return join(home, HOME_SKILLS_DIR_OVERRIDES[provider]);
|
||||
const override = HOME_SKILLS_DIR_OVERRIDES[provider];
|
||||
if (override) return override(home);
|
||||
return join(home, provider, 'skills');
|
||||
}
|
||||
|
||||
@@ -861,10 +877,15 @@ function collectInstallDetections(root, home = homedir()) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const { home: h, provider } of GLOBAL_HARNESS_HINTS) {
|
||||
const foundPath = join(home, h);
|
||||
for (const hint of GLOBAL_HARNESS_HINTS) {
|
||||
const { provider } = hint;
|
||||
// A hint is either a fixed dir under home or a resolver for harnesses
|
||||
// whose location depends on the environment (OpenCode's config dir).
|
||||
const foundPath = hint.resolve ? hint.resolve(home) : join(home, hint.home);
|
||||
if (!existsSync(foundPath)) continue;
|
||||
const skillProbePaths = userSkillProbePaths(home, h, provider);
|
||||
const skillProbePaths = hint.resolve
|
||||
? uniquePaths([userProviderSkillsDir(home, provider), join(foundPath, 'skills')])
|
||||
: userSkillProbePaths(home, hint.home, provider);
|
||||
detections.push({
|
||||
provider,
|
||||
scope: 'user',
|
||||
@@ -1137,6 +1158,32 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
|
||||
copyDirSync(src, dest);
|
||||
written++;
|
||||
}
|
||||
// A pre-#406 global OpenCode install lived at ~/.opencode/skills, a
|
||||
// location OpenCode never reads. Now that the real copy sits in the
|
||||
// config dir, drop exactly the skills just written from the stranded
|
||||
// location; sibling skills and everything else in ~/.opencode stay.
|
||||
// Guards (both flagged in review): a symlinked skills dir is shared
|
||||
// storage whose target must not be emptied through the link, the
|
||||
// just-written dir must be compared by realpath rather than string,
|
||||
// and a home-rooted repo makes `.opencode/skills` a live
|
||||
// project-scope install rather than a stranded global one.
|
||||
if (scope === 'user' && provider === '.opencode') {
|
||||
const legacyDir = join(root, '.opencode', 'skills');
|
||||
let migratable = false;
|
||||
try {
|
||||
migratable = existsSync(legacyDir)
|
||||
&& !lstatSync(legacyDir).isSymbolicLink()
|
||||
&& realpathSync(legacyDir) !== realpathSync(localSkillsDir)
|
||||
&& !existsSync(join(root, '.git'));
|
||||
} catch { migratable = false; }
|
||||
if (migratable) {
|
||||
for (const skill of readdirSync(srcDir, { withFileTypes: true })) {
|
||||
if (!skill.isDirectory()) continue;
|
||||
rmSync(join(legacyDir, skill.name), { recursive: true, force: true });
|
||||
}
|
||||
try { rmdirSync(legacyDir); } catch { /* not empty: siblings stay */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return written;
|
||||
@@ -1179,6 +1226,19 @@ function hookArtifactsForProvider(bundleDir, root, provider) {
|
||||
});
|
||||
}
|
||||
|
||||
// The project-relative hook command path for a provider, used for project-scope
|
||||
// installs (skillRoot === root). Derived rather than copied from the bundle: the
|
||||
// Codex bundle ships a `.codex/skills/...` command (correct for a `.codex`-
|
||||
// directory install), but the CLI lays Codex's skill down at `.agents/skills/`,
|
||||
// so preserving the bundle token would point the hook at a nonexistent file and
|
||||
// silently no-op it. Claude keeps its ${CLAUDE_PROJECT_DIR} token so a manifest
|
||||
// read from a nested cwd (or copied into settings.local.json) still resolves.
|
||||
function hookScriptRelPathForProvider(provider) {
|
||||
const script = provider === '.cursor' ? 'hook-before-edit.mjs' : 'hook.mjs';
|
||||
const rel = `${provider}/skills/impeccable/scripts/${script}`;
|
||||
return provider === '.claude' ? '${CLAUDE_PROJECT_DIR}/' + rel : rel;
|
||||
}
|
||||
|
||||
function hookScriptPathForProvider(skillRoot, provider) {
|
||||
// `.github` is intentionally absent: its hook manifest (`.github/hooks/
|
||||
// impeccable.json`) is a committed, team-shared file that the Copilot cloud
|
||||
@@ -1195,21 +1255,53 @@ function hookScriptPathForProvider(skillRoot, provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function rewriteHookCommandsForSkillRoot(value, provider, skillRoot) {
|
||||
// Wrap a `node "PATH"` hook command so a missing skill file is a silent no-op
|
||||
// (exit 0) instead of a Node module-resolution crash. hook.mjs promises to
|
||||
// "never break a turn. Always exit 0.", but that only holds once Node can load
|
||||
// the file; a stale/missing path crashes before any of that logic runs. The
|
||||
// `[ ! -f X ] || node X` form (NOT `... || true`) preserves Node's own exit
|
||||
// code when the file exists, so Claude's exit-2 blocking signal still reaches
|
||||
// the agent. POSIX-shell form, consistent with the project's other hook
|
||||
// commands (e.g. the GitHub manifest's `$(git rev-parse ...)`).
|
||||
function guardHookCommand(quotedPath) {
|
||||
return `[ ! -f ${quotedPath} ] || node ${quotedPath}`;
|
||||
}
|
||||
|
||||
// Transform bundled hook commands for the actual install target:
|
||||
// * absolute — rewrite the (marker) command to the resolved absolute skill
|
||||
// path. Required when the manifest is a user/global file (~/.claude/
|
||||
// settings.local.json) that fires in EVERY project, so ${CLAUDE_PROJECT_DIR}
|
||||
// would resolve per-project to dirs without a skill copy (issue #399); also
|
||||
// when a project hook points at a skill installed elsewhere (--scope=global).
|
||||
// * otherwise — keep the bundle's own ${CLAUDE_PROJECT_DIR}-relative path,
|
||||
// which correctly resolves for a project-scoped install.
|
||||
// Either way the command is wrapped with the missing-file guard.
|
||||
function rewriteHookCommandsForSkillRoot(value, provider, { skillRoot, absolute }) {
|
||||
const hookScript = hookScriptPathForProvider(skillRoot, provider);
|
||||
// Providers we don't own a `node "PATH"` command hook for (.github, .grok)
|
||||
// carry their own portable command forms; leave them untouched.
|
||||
if (!hookScript) return value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (valueHasImpeccableHookMarker(value)) return `node ${JSON.stringify(hookScript)}`;
|
||||
return value;
|
||||
if (!valueHasImpeccableHookMarker(value)) return value;
|
||||
let quotedPath;
|
||||
if (absolute) {
|
||||
quotedPath = JSON.stringify(hookScript);
|
||||
} else {
|
||||
// Project-scope install: derive the provider's own project-relative path
|
||||
// rather than trusting the bundle token, which for Codex points at
|
||||
// `.codex/skills/...` while the CLI installs the skill at `.agents/skills/`.
|
||||
quotedPath = JSON.stringify(hookScriptRelPathForProvider(provider));
|
||||
}
|
||||
return guardHookCommand(quotedPath);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => rewriteHookCommandsForSkillRoot(item, provider, skillRoot));
|
||||
return value.map(item => rewriteHookCommandsForSkillRoot(item, provider, { skillRoot, absolute }));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const next = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
next[key] = rewriteHookCommandsForSkillRoot(child, provider, skillRoot);
|
||||
next[key] = rewriteHookCommandsForSkillRoot(child, provider, { skillRoot, absolute });
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -1391,9 +1483,14 @@ function copyProviderHooks(bundleDir, root, providers, { force = false, skillRoo
|
||||
}
|
||||
|
||||
const freshManifest = readJsonFile(src, 'Bundled hook manifest');
|
||||
const fresh = skillRoot === root
|
||||
? freshManifest
|
||||
: rewriteHookCommandsForSkillRoot(freshManifest, provider, skillRoot);
|
||||
// Rewrite to an absolute skill path when the skill lives elsewhere than
|
||||
// this manifest's root (--scope=global project hook) OR when the manifest
|
||||
// itself is a user/global file. A global settings file fires in every
|
||||
// project, so ${CLAUDE_PROJECT_DIR} there crashes Node wherever no local
|
||||
// skill copy exists (issue #399); the resolved absolute path is correct
|
||||
// for the one global skill it targets.
|
||||
const absolute = skillRoot !== root || isHomeDir(root);
|
||||
const fresh = rewriteHookCommandsForSkillRoot(freshManifest, provider, { skillRoot, absolute });
|
||||
let next = fresh;
|
||||
|
||||
if (existsSync(dest)) {
|
||||
@@ -1741,6 +1838,67 @@ function findInstalledProviders(root, scope) {
|
||||
return found;
|
||||
}
|
||||
|
||||
// Like findInstalledProviders, but only counts a provider whose skills dir
|
||||
// actually holds the IMPECCABLE skill (canonical, prefixed, or legacy teach-).
|
||||
// `update` uses this so it never mistakes a repo that vendors OTHER first-party
|
||||
// skills under .claude/skills for an impeccable install and drops a copy in
|
||||
// (issue #399, part 2).
|
||||
function findImpeccableProviders(root, scope) {
|
||||
const found = [];
|
||||
for (const d of PROVIDER_DIRS) {
|
||||
for (const skillsDir of existingSkillsDirs(root, d, scope)) {
|
||||
let entries;
|
||||
try { entries = readdirSync(skillsDir); } catch { continue; }
|
||||
if (entries.some(e =>
|
||||
e === 'impeccable' || e.endsWith('-impeccable') ||
|
||||
e === 'teach-impeccable' || e.endsWith('-teach-impeccable')
|
||||
)) {
|
||||
found.push(d);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// Resolve which install `skills update` should refresh: project-level (the CWD's
|
||||
// git root) or user-level (~/.claude etc.). Returns a plain descriptor; the
|
||||
// caller handles the interactive both-exist prompt via `ambiguous`.
|
||||
function resolveUpdateTarget({ projectRoot, home, explicitScope }) {
|
||||
// A home-rooted repo (a dotfiles checkout at $HOME) overlaps project and user
|
||||
// installs under one root; keep the historical unscoped scan so overlapping
|
||||
// layouts (e.g. Pi's ~/.pi/skills and ~/.pi/agent/skills) both refresh.
|
||||
const homeRooted = isHomeDir(projectRoot);
|
||||
if (homeRooted && !explicitScope) {
|
||||
const providers = findInstalledProviders(home);
|
||||
return providers.length ? { root: home, scope: undefined, providers, scopeLabel: 'user level' } : null;
|
||||
}
|
||||
|
||||
const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project');
|
||||
const userProviders = findImpeccableProviders(home, 'user');
|
||||
|
||||
if (explicitScope === 'user') {
|
||||
return userProviders.length
|
||||
? { root: home, scope: 'user', providers: userProviders, scopeLabel: 'user level' }
|
||||
: null;
|
||||
}
|
||||
if (explicitScope === 'project') {
|
||||
return projectProviders.length
|
||||
? { root: projectRoot, scope: 'project', providers: projectProviders, scopeLabel: 'this project' }
|
||||
: null;
|
||||
}
|
||||
if (projectProviders.length && userProviders.length) {
|
||||
return { ambiguous: true, projectRoot, home, projectProviders, userProviders };
|
||||
}
|
||||
if (projectProviders.length) {
|
||||
return { root: projectRoot, scope: 'project', providers: projectProviders, scopeLabel: 'this project' };
|
||||
}
|
||||
if (userProviders.length) {
|
||||
return { root: home, scope: 'user', providers: userProviders, scopeLabel: 'user level' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLinkedProviders(root, providers, scope) {
|
||||
return providers.filter(provider => {
|
||||
for (const skillsDir of providerSkillsDirCandidates(root, provider, scope)) {
|
||||
@@ -1800,21 +1958,56 @@ async function update(flags = []) {
|
||||
const yes = flags.includes('-y') || flags.includes('--yes');
|
||||
const force = flags.includes('--force');
|
||||
const installHooks = !flags.includes('--no-hooks');
|
||||
const scopeValue = getInstallScopeValue(flags);
|
||||
const explicitScope = normalizeInstallScope(scopeValue);
|
||||
if (scopeValue && !explicitScope) {
|
||||
console.error(`Unknown update scope: ${scopeValue}. Use --project or --user.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Download the latest skills directly from impeccable.style.
|
||||
// We skip `npx skills update` because it has a known upstream bug
|
||||
// (vercel-labs/skills#775) where it can't find the lock file.
|
||||
const root = findProjectRoot();
|
||||
const providers = findInstalledProviders(root);
|
||||
const linkedProviders = findLinkedProviders(root, providers);
|
||||
const copyProviders = providers.filter(provider => !linkedProviders.includes(provider));
|
||||
const projectRoot = findProjectRoot();
|
||||
const home = homedir();
|
||||
|
||||
if (providers.length === 0) {
|
||||
console.log('No impeccable skill folders found in this project.');
|
||||
let target = resolveUpdateTarget({ projectRoot, home, explicitScope });
|
||||
if (!target) {
|
||||
if (explicitScope) {
|
||||
const where = explicitScope === 'user' ? `user level (${formatPathForDisplay(home)})` : `this project (${projectRoot})`;
|
||||
console.log(`No impeccable skill folders found at the ${where}.`);
|
||||
} else {
|
||||
console.log('No impeccable skill folders found in this project or at the user level.');
|
||||
}
|
||||
console.log('Run `npx impeccable install` to install first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Both a project and a user-level install exist and no scope was given. Never
|
||||
// silently pick (issue #399, part 2): prompt when interactive, else default to
|
||||
// the project and say how to target the other.
|
||||
if (target.ambiguous) {
|
||||
console.log('Impeccable is installed both here and at the user level:');
|
||||
console.log(` project ${projectRoot} (${target.projectProviders.join(', ')})`);
|
||||
console.log(` user level ${formatPathForDisplay(home)} (${target.userProviders.join(', ')})`);
|
||||
let pickUser = false;
|
||||
if (!yes && process.stdin.isTTY) {
|
||||
const ans = await ask('Update which? [project]/user: ');
|
||||
pickUser = ['user', 'u', 'global', 'home'].includes(ans);
|
||||
} else {
|
||||
console.log('Defaulting to the project. Re-run with --user to update the user-level install instead.');
|
||||
}
|
||||
target = pickUser
|
||||
? { root: home, scope: 'user', providers: target.userProviders, scopeLabel: 'user level' }
|
||||
: { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' };
|
||||
}
|
||||
|
||||
const { root, scope } = target;
|
||||
console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`);
|
||||
const providers = target.providers;
|
||||
const linkedProviders = findLinkedProviders(root, providers, scope);
|
||||
const copyProviders = providers.filter(provider => !linkedProviders.includes(provider));
|
||||
|
||||
if (linkedProviders.length > 0) {
|
||||
console.log(`Linked skills found in: ${linkedProviders.join(', ')}`);
|
||||
console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable link --source=.impeccable` if new skills are added.');
|
||||
@@ -1833,12 +2026,12 @@ async function update(flags = []) {
|
||||
}
|
||||
|
||||
// Compare local vs remote -- skip if already up to date
|
||||
if (isUpToDate(root, copyProviders, tmpDir)) {
|
||||
if (isUpToDate(root, copyProviders, tmpDir, scope)) {
|
||||
try {
|
||||
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
const v = getSkillsVersion(root);
|
||||
const v = getSkillsVersion(root, scope);
|
||||
console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
|
||||
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
|
||||
console.log('Nothing else to do.');
|
||||
@@ -1865,16 +2058,16 @@ async function update(flags = []) {
|
||||
|
||||
// Retire any old `i-`-prefixed install up front so the refresh lands on the
|
||||
// canonical `impeccable` dir rather than orphaning the prefixed copy.
|
||||
const migrated = migrateUnprefixImpeccable(root);
|
||||
const migrated = migrateUnprefixImpeccable(root, scope);
|
||||
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
|
||||
|
||||
const updated = refreshProviderSkills(tmpDir, root, copyProviders);
|
||||
const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope);
|
||||
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
const v = getSkillsVersion(root);
|
||||
const v = getSkillsVersion(root, scope);
|
||||
console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
|
||||
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
|
||||
console.log('Done!\n');
|
||||
|
||||
@@ -530,7 +530,11 @@ if (IS_BROWSER) {
|
||||
function generateSelector(el) {
|
||||
if (el === document.body) return 'body';
|
||||
if (el === document.documentElement) return 'html';
|
||||
if (el.id) return '#' + CSS.escape(el.id);
|
||||
// Read via getAttribute when `el.id` is not a string — a <form> with a
|
||||
// named control (e.g. <input name="id">) shadows the builtin getter and
|
||||
// returns the element, producing a garbage `#[object …]` selector (#407).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
|
||||
if (elId) return '#' + CSS.escape(elId);
|
||||
|
||||
const parts = [];
|
||||
let current = el;
|
||||
@@ -1223,6 +1227,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1463,8 +1471,11 @@ if (IS_BROWSER) {
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
|
||||
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||
// Skip browser extension elements (Claude, etc.)
|
||||
const elId = el.id || '';
|
||||
// Skip browser extension elements (Claude, etc.). Use getAttribute when
|
||||
// `el.id` is not a string: a <form> with a named control like
|
||||
// <input name="id"> shadows the builtin `id` getter and returns the
|
||||
// element, whose `.startsWith` throws (issue #407).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
||||
// Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).
|
||||
// These are inspector chrome, not part of the user's design.
|
||||
@@ -1479,6 +1490,7 @@ if (IS_BROWSER) {
|
||||
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementRadialSpotlightDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
@@ -1541,6 +1553,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
+113
-19
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { loadDesignSystemForTarget } 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';
|
||||
@@ -27,9 +28,37 @@ function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
@@ -44,7 +73,28 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -52,7 +102,11 @@ function formatFindings(findings, jsonMode) {
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStdin(options = {}) {
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
@@ -60,11 +114,12 @@ async function handleStdin(options = {}) {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', options);
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +155,14 @@ Options:
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -139,6 +200,7 @@ async function detectCli() {
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
@@ -199,14 +261,23 @@ async function detectCli() {
|
||||
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
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
if (viewport) scanOptions.viewport = viewport;
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -214,7 +285,7 @@ async function detectCli() {
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
@@ -228,10 +299,17 @@ async function detectCli() {
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
: (url) => detectUrl(url, scanOptions);
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
@@ -297,11 +375,14 @@ async function detectCli() {
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, scanOptions);
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
@@ -316,10 +397,11 @@ async function detectCli() {
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,12 +412,24 @@ async function detectCli() {
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
@@ -7,6 +8,11 @@ import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
@@ -469,6 +475,62 @@ function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -251,6 +260,15 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'radial-spotlight-glow',
|
||||
category: 'slop',
|
||||
name: 'Decorative radial spotlight glow',
|
||||
description:
|
||||
'A soft, low-opacity accent-colored radial gradient fading to transparent, dropped behind a hero or section as a "spotlight." It is a reflex AI decoration — the translucent cousin of the saturated radial halo. Let the surface stand on its own, or light the composition with a deliberate material accent rather than a floating colored haze.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'marquee',
|
||||
category: 'slop',
|
||||
@@ -315,9 +333,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
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.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +530,14 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
@@ -880,9 +911,21 @@ function checkColors(opts) {
|
||||
const findings = [];
|
||||
|
||||
if (hasDirectText && textColor && !isEmojiOnly) {
|
||||
// Gradient-clipped text (`background-clip: text`, typically with a
|
||||
// transparent text-fill) paints its glyphs *with* the element's own
|
||||
// gradient. The `color` value the cascade still reports is never painted,
|
||||
// and the gradient is the fill, not a backdrop — so measuring `color`
|
||||
// against that gradient (which resolveGradientStops picks up as the
|
||||
// element's own background-image) is a guaranteed false positive
|
||||
// (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
|
||||
// rule below still flags the pattern itself. Skipping a rule beats a false
|
||||
// positive here — the true painted contrast can't be measured from `color`.
|
||||
const isGradientClippedText = bgClip === 'text';
|
||||
// Run background-dependent checks against either a solid bg or, if the
|
||||
// ancestor is a gradient, against every gradient stop (use the worst case).
|
||||
const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
|
||||
const bgs = isGradientClippedText
|
||||
? null
|
||||
: (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
|
||||
if (bgs) {
|
||||
// Gray on colored background — flag if every stop is chromatic
|
||||
const textLum = relativeLuminance(textColor);
|
||||
@@ -1582,7 +1625,13 @@ function isZeroOffset(value) {
|
||||
// never see it — pseudo-elements aren't part of the DOM the cascade walks —
|
||||
// so this scans stylesheet text directly, mirroring the border rule's
|
||||
// gates: >= 3px thick, chromatic fill, full height against a side edge.
|
||||
function scanCssTextForPseudoStripe(content) {
|
||||
function scanCssTextForPseudoStripe(rawContent) {
|
||||
// Blank comment bodies byte-for-byte so commented-out rules are not
|
||||
// scanned as live CSS and every rule keeps its source offset (each
|
||||
// finding carries `index` so line-based callers can attribute it and
|
||||
// line-scoped inline ignores can match).
|
||||
const content = String(rawContent || '').replace(/\/\*[\s\S]*?\*\//g,
|
||||
(block) => block.replace(/[^\n]/g, ' '));
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const findings = [];
|
||||
const seen = new Set();
|
||||
@@ -1691,9 +1740,13 @@ function scanCssTextForPseudoStripe(content) {
|
||||
|
||||
if (seen.has(selector)) continue;
|
||||
seen.add(selector);
|
||||
// The selector group absorbs whitespace trailing the previous rule;
|
||||
// advance past it so `index` points at the selector itself.
|
||||
const selectorStart = m.index + (m[1].length - m[1].trimStart().length);
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
index: selectorStart,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
@@ -2440,29 +2493,54 @@ function resolveBackground(el, win, customPropMap) {
|
||||
// Walk parents looking for a gradient background and return its color stops.
|
||||
// Used as a fallback when resolveBackground() returns null because the
|
||||
// effective background is a gradient (no single solid color to compare against).
|
||||
function resolveGradientStops(el, win) {
|
||||
function resolveGradientStops(el, win, customPropMap) {
|
||||
let current = el;
|
||||
while (current && current.nodeType === 1) {
|
||||
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||
const bgImage = style.backgroundImage || '';
|
||||
let stops = null;
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
const stops = parseGradientColors(bgImage);
|
||||
if (stops.length > 0) return stops;
|
||||
const parsed = parseGradientColors(bgImage);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
if (!DETECTOR_IS_BROWSER) {
|
||||
if (!stops && !DETECTOR_IS_BROWSER) {
|
||||
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||
const stops = parseGradientColors(bgMatch[1]);
|
||||
if (stops.length > 0) return stops;
|
||||
const parsed = parseGradientColors(bgMatch[1]);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
}
|
||||
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A translucent gradient stop (e.g. a faint `rgba(52,192,168,0.09)` accent
|
||||
// glow) paints over whatever surface sits beneath the gradient — the browser
|
||||
// composites it, so its effective color is far closer to the base than to the
|
||||
// full-opacity accent. Treating the stop as opaque flags every text child of a
|
||||
// softly-glowing section as low-contrast (issue #409 Case B). Composite each
|
||||
// alpha stop over the resolved surface beneath the gradient element. When that
|
||||
// surface isn't resolvable (another gradient above, no opaque ancestor), drop
|
||||
// the translucent stop rather than guess: a dropped stop can't manufacture a
|
||||
// false finding, and skipping beats a wrong ratio.
|
||||
function compositeGradientStops(stops, gradientEl, win, customPropMap) {
|
||||
const hasAlpha = stops.some(s => (s.a ?? 1) < 0.99);
|
||||
if (!hasAlpha) return stops;
|
||||
const base = resolveBackground(gradientEl.parentElement || gradientEl, win, customPropMap);
|
||||
const out = [];
|
||||
for (const s of stops) {
|
||||
const a = s.a ?? 1;
|
||||
if (a >= 0.99) { out.push(s); continue; }
|
||||
if (base) out.push(compositeColorOver(s, base));
|
||||
// else: unresolvable base — drop the translucent stop (skip, don't guess).
|
||||
}
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
// Parse a single CSS length token to pixels. Accepts "12px", "50%", a
|
||||
// shorthand like "12px 4px" (uses the first value), or empty / null.
|
||||
// Returns the pixel value, or null when the input is unparseable.
|
||||
@@ -3367,6 +3445,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3473,6 +3578,131 @@ function checkElementAIPaletteDOM(el) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── Decorative radial spotlight glow ───────────────────────────────────────
|
||||
// A soft, low-opacity chromatic radial-gradient fading to transparent, painted
|
||||
// as a decorative wash behind a hero or section. The translucent sibling of the
|
||||
// `radial-halo` tell: `radial-halo` requires a saturated, near-opaque center on
|
||||
// a dark page; this catches the low-alpha "spotlight" the halo gate lets slip
|
||||
// (e.g. `radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26),
|
||||
// transparent 44%)`). The two alpha bands are disjoint, so they never
|
||||
// double-report the same declaration.
|
||||
const SPOTLIGHT_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i;
|
||||
|
||||
// Parse the FIRST non-repeating radial-gradient in a background value into its
|
||||
// ordered color stops. Each stop is { color: {r,g,b,a} | null, transparent }.
|
||||
// Returns null when there is no plain radial-gradient to read.
|
||||
function parseRadialGradientStops(value) {
|
||||
if (!value || !/radial-gradient/i.test(value)) return null;
|
||||
const gradRe = /(repeating-)?radial-gradient\(/gi;
|
||||
let g;
|
||||
while ((g = gradRe.exec(value)) !== null) {
|
||||
if (g[1]) continue; // repeating-* is a pattern, not a spotlight
|
||||
let depth = 0, end = -1;
|
||||
const open = value.indexOf('(', g.index);
|
||||
for (let i = open; i < value.length; i++) {
|
||||
if (value[i] === '(') depth++;
|
||||
else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end < 0) return null;
|
||||
const args = splitTopLevelCommas(value.slice(open + 1, end));
|
||||
// The optional prelude (shape / size / `at <pos>`) carries no color token.
|
||||
const stopArgs = args.filter(a => SPOTLIGHT_COLOR_TOKEN_RE.test(a));
|
||||
if (stopArgs.length < 2) return null;
|
||||
return stopArgs.map(a => {
|
||||
const tok = a.match(SPOTLIGHT_COLOR_TOKEN_RE);
|
||||
if (!tok) return { color: null, transparent: false };
|
||||
if (/^transparent$/i.test(tok[0])) return { color: null, transparent: true };
|
||||
const color = parseAnyColor(tok[0]);
|
||||
return { color, transparent: !!color && (color.a ?? 1) <= 0.05 };
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pure gate. `label` is a stable identifier the fixture test keys on.
|
||||
function checkRadialSpotlight({ gradientValue, width, height, label }) {
|
||||
const stops = parseRadialGradientStops(gradientValue);
|
||||
if (!stops || stops.length < 2) return [];
|
||||
|
||||
// Must fade OUT: the last stop is transparent / near-zero alpha. A gradient
|
||||
// between two visible surfaces is a real background, not a floating glow.
|
||||
const last = stops[stops.length - 1];
|
||||
const lastAlpha = last.transparent ? 0 : (last.color ? (last.color.a ?? 1) : 1);
|
||||
if (lastAlpha > 0.05) return [];
|
||||
|
||||
// The visible (non-transparent, parseable) color stops.
|
||||
const colored = stops.filter(s => !s.transparent && s.color && (s.color.a ?? 1) > 0.05);
|
||||
if (colored.length === 0) return [];
|
||||
// One soft glow, not a multi-color composition: at most two visible stops.
|
||||
if (colored.length > 2) return [];
|
||||
// Every visible stop must be LOW opacity. Any opaque stop means a real fill
|
||||
// or a saturated halo (`radial-halo`'s job), not this translucent spotlight.
|
||||
if (colored.some(s => (s.color.a ?? 1) >= 0.45)) return [];
|
||||
// At least one visible stop must be chromatic. A neutral (grayscale)
|
||||
// near-black / near-white vignette is a legitimate lighting move, exempt.
|
||||
const chromatic = colored.find(s => hasChroma(s.color, 24));
|
||||
if (!chromatic) return [];
|
||||
|
||||
// Decorative-scale gate. Badges, avatars, and actual small "lights" are
|
||||
// exempt; a spotlight glow only reads as slop when it washes a large surface.
|
||||
if (!(width >= 240 && height >= 160)) return [];
|
||||
|
||||
const alpha = (chromatic.color.a ?? 1).toFixed(2);
|
||||
const name = label || 'section';
|
||||
return [{
|
||||
id: 'radial-spotlight-glow',
|
||||
snippet: `radial-gradient spotlight glow "${name}" (${colorToHex(chromatic.color)} a${alpha} → transparent) on ${Math.round(width)}x${Math.round(height)} surface`,
|
||||
}];
|
||||
}
|
||||
|
||||
// Read the raw radial-gradient source off an element's computed style, with a
|
||||
// fallback to the `background` shorthand and the inline style attribute for
|
||||
// engines that don't decompose the shorthand into backgroundImage.
|
||||
function elementGradientValue(style, el) {
|
||||
const bgImage = style.backgroundImage && style.backgroundImage !== 'none' ? style.backgroundImage : '';
|
||||
if (/radial-gradient/i.test(bgImage)) return bgImage;
|
||||
const bg = style.background || '';
|
||||
if (/radial-gradient/i.test(bg)) return bg;
|
||||
const rawStyle = el?.getAttribute?.('style') || '';
|
||||
const m = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (m && /radial-gradient/i.test(m[1])) return m[1];
|
||||
return '';
|
||||
}
|
||||
|
||||
function spotlightLabel(el) {
|
||||
const dataName = el.getAttribute?.('data-name');
|
||||
if (dataName) return dataName;
|
||||
if (typeof el.id === 'string' && el.id) return el.id;
|
||||
const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/)[0] : '';
|
||||
if (cls) return cls;
|
||||
return el.tagName ? el.tagName.toLowerCase() : 'section';
|
||||
}
|
||||
|
||||
function checkElementRadialSpotlightDOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
const gradientValue = elementGradientValue(style, el);
|
||||
if (!gradientValue) return [];
|
||||
const rect = el.getBoundingClientRect();
|
||||
return checkRadialSpotlight({
|
||||
gradientValue,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
label: spotlightLabel(el),
|
||||
});
|
||||
}
|
||||
|
||||
function checkElementRadialSpotlight(el, style, tag, window) {
|
||||
const gradientValue = elementGradientValue(style, el);
|
||||
if (!gradientValue) return [];
|
||||
// Static engine does no layout — read explicit pixel dimensions from CSS.
|
||||
return checkRadialSpotlight({
|
||||
gradientValue,
|
||||
width: parseFloat(style.width) || 0,
|
||||
height: parseFloat(style.height) || 0,
|
||||
label: spotlightLabel(el),
|
||||
});
|
||||
}
|
||||
|
||||
const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
|
||||
|
||||
// Resolve a CSS font-size value to pixels by walking up the parent chain.
|
||||
@@ -3594,6 +3824,55 @@ function textDescendantsFlushSides(el, rect) {
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Screen-reader-only ("visually hidden") text is exempt from the tiny-text
|
||||
// floors: it is never rendered, so its size is irrelevant. Detect the two
|
||||
// standard idioms — a known sr-only class on the element or an ancestor, and
|
||||
// the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
|
||||
// browser (computed styles).
|
||||
const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]';
|
||||
function isVisuallyHidden(el, style) {
|
||||
if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
|
||||
const pos = style.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
const clip = style.clip || '';
|
||||
const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
|
||||
if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
|
||||
const w = parseFloat(style.width);
|
||||
const h = parseFloat(style.height);
|
||||
const overflow = style.overflow || '';
|
||||
if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Elements whose text is never painted: document metadata and script/style
|
||||
// payloads. Their JS / CSS / JSON-LD text satisfies `hasDirectText`, and on
|
||||
// sites that set `html { font-size: 62.5% }` their inherited computed size is
|
||||
// 10px — so the text-size floors flag them as tiny body copy even though
|
||||
// nothing renders (issue #408: dozens of phantom "10px body text" findings on
|
||||
// every Shopify page). Exclude them, plus anything the cascade resolves to
|
||||
// display:none / visibility:hidden. The jsdom path can't lay out, so the
|
||||
// tag/attribute-based exclusions carry the weight there; the display checks are
|
||||
// computed-style reads that resolve without layout in both adapters.
|
||||
const NON_RENDERED_TAGS = new Set([
|
||||
'script', 'style', 'title', 'noscript', 'template', 'head',
|
||||
'meta', 'link', 'base', 'param', 'source', 'track', 'datalist',
|
||||
'col', 'colgroup', 'map', 'area',
|
||||
]);
|
||||
function isNonRenderedText(el, tag, style) {
|
||||
const t = (tag || '').toLowerCase();
|
||||
if (NON_RENDERED_TAGS.has(t)) return true;
|
||||
// Descendants of <head> never render even when the tag itself would
|
||||
// (some sites nest <noscript>/<template> content there).
|
||||
if (el && el.closest && el.closest('head')) return true;
|
||||
if (style) {
|
||||
if (style.display === 'none') return true;
|
||||
const vis = style.visibility;
|
||||
if (vis === 'hidden' || vis === 'collapse') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -3604,8 +3883,13 @@ function textDescendantsFlushSides(el, rect) {
|
||||
function checkQuality(opts) {
|
||||
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
|
||||
const findings = [];
|
||||
// Skip browser extension injected elements
|
||||
const elId = el.id || '';
|
||||
// Skip browser extension injected elements. Read the id via getAttribute
|
||||
// whenever `el.id` is not a string: on a <form> (and other
|
||||
// [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
|
||||
// shadows the builtin `id` getter and returns the control element, whose
|
||||
// `.startsWith` is undefined and throws (issue #407 — every Shopify product
|
||||
// form ships an <input name="id">).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
||||
|
||||
// --- Line length too long --- (browser-only: needs rect.width)
|
||||
@@ -3873,11 +4157,67 @@ function checkQuality(opts) {
|
||||
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
|
||||
const isUppercase = style.textTransform === 'uppercase';
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase && !isNonRenderedText(el, tag, style)) {
|
||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undersized functional / UI text ---
|
||||
// Complements `tiny-text` above, which owns long body copy and deliberately
|
||||
// EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
|
||||
// uppercase micro-labels). This rule targets exactly that blind spot: the
|
||||
// interactive and short content-bearing text — nav items, buttons, labels,
|
||||
// table cells, meta rows, timecodes — shipped below an 11px floor.
|
||||
//
|
||||
// The live failure it closes: a build shipped its entire furniture layer at
|
||||
// 8px, and the design hook waved it through because 8px had been added to
|
||||
// the DESIGN.md size ramp. Being on the ramp is a token argument, not a
|
||||
// legibility one, so this rule ignores the design system entirely — a value
|
||||
// on the ramp is still flagged.
|
||||
//
|
||||
// Floors: 11px for anything functional. The floor holds inside a footer;
|
||||
// only NON-interactive legal smallprint gets the softer 10px floor. Exempts
|
||||
// sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
|
||||
// Uppercase letterspaced micro-labels are still functional — not exempt.
|
||||
{
|
||||
const directText = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent || '')
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const dtLen = directText.length;
|
||||
// `option` renders (in native select popups) so it stays a local skip;
|
||||
// script/style/title/noscript/head-descendants and display:none /
|
||||
// visibility:hidden are handled by isNonRenderedText (shared with tiny-text).
|
||||
const UI_SKIP_TAGS = new Set(['sub', 'sup', 'option']);
|
||||
// jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
|
||||
// text that computes at or above the floor never reaches here. The browser
|
||||
// adapter additionally catches values only resolvable with real layout
|
||||
// (e.g. viewport-relative units, cascade winners set in linked sheets).
|
||||
if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !isNonRenderedText(el, tag, style)) {
|
||||
const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]';
|
||||
const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
|
||||
if (!isExemptContext && !isVisuallyHidden(el, style)) {
|
||||
const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]';
|
||||
const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]';
|
||||
const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]';
|
||||
const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
|
||||
const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
|
||||
const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
|
||||
const floor = (!isInteractive && isSmallprint) ? 10 : 11;
|
||||
// Fire on functional text only: interactive, structural furniture, or
|
||||
// any short (<=20-char) run — the label / meta / timecode shape. Long
|
||||
// non-furniture body copy stays with `tiny-text`, so the two rules
|
||||
// never double-flag the same element.
|
||||
if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
|
||||
const excerpt = directText.slice(0, 40);
|
||||
findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- All-caps body text ---
|
||||
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
||||
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
@@ -4068,7 +4408,7 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
||||
textColor,
|
||||
bgColor: ownBg,
|
||||
effectiveBg: finalEffectiveBg,
|
||||
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window),
|
||||
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
@@ -6373,7 +6713,11 @@ if (IS_BROWSER) {
|
||||
function generateSelector(el) {
|
||||
if (el === document.body) return 'body';
|
||||
if (el === document.documentElement) return 'html';
|
||||
if (el.id) return '#' + CSS.escape(el.id);
|
||||
// Read via getAttribute when `el.id` is not a string — a <form> with a
|
||||
// named control (e.g. <input name="id">) shadows the builtin getter and
|
||||
// returns the element, producing a garbage `#[object …]` selector (#407).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
|
||||
if (elId) return '#' + CSS.escape(elId);
|
||||
|
||||
const parts = [];
|
||||
let current = el;
|
||||
@@ -7066,6 +7410,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7306,8 +7654,11 @@ if (IS_BROWSER) {
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
|
||||
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||
// Skip browser extension elements (Claude, etc.)
|
||||
const elId = el.id || '';
|
||||
// Skip browser extension elements (Claude, etc.). Use getAttribute when
|
||||
// `el.id` is not a string: a <form> with a named control like
|
||||
// <input name="id"> shadows the builtin `id` getter and returns the
|
||||
// element, whose `.startsWith` throws (issue #407).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
||||
// Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).
|
||||
// These are inspector chrome, not part of the user's design.
|
||||
@@ -7322,6 +7673,7 @@ if (IS_BROWSER) {
|
||||
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementRadialSpotlightDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
@@ -7384,6 +7736,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { scanCssTextForGlow, scanCssTextForGridBackground, scanCssTextForMarquee, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
|
||||
import { scanCssTextForGlow, scanCssTextForGridBackground, scanCssTextForMarquee, scanCssTextForPseudoStripe, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
@@ -16,6 +16,7 @@ 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) {
|
||||
@@ -306,9 +307,16 @@ const REGEX_ANALYZERS = [
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 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).
|
||||
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
||||
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
||||
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
||||
// its threshold is deliberately conservative. Two gates must both hold:
|
||||
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
||||
// never fires, no matter how short.
|
||||
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
||||
// of body text, so a long article that uses eight across several thousand
|
||||
// words is left alone while a short, dash-per-clause landing page is not.
|
||||
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
||||
//
|
||||
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
||||
// a model that writes `—`, `—`, or `—` renders an em-dash
|
||||
@@ -322,7 +330,11 @@ const REGEX_ANALYZERS = [
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
||||
// a long document. textLength <= count * chars-per-dash means the density is
|
||||
// at or above the threshold.
|
||||
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
@@ -641,7 +653,21 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
if (cssLike.has(ext)) findings.push(...scanInsetStripeCss(content, filePath));
|
||||
// Pseudo-element stripes (::before/::after absolute bars) carry the same
|
||||
// side-tab silhouette without any border token, so the line matchers can't
|
||||
// see them (issue #394). The shared scanner already runs on full HTML pages
|
||||
// via checkHtmlPatterns; give standalone stylesheets, component style
|
||||
// blocks, and CSS-in-JS templates the same coverage. Each hit carries the
|
||||
// rule's source offset, so the finding gets a real line and line-scoped
|
||||
// inline ignores keep working.
|
||||
const pseudoStripeFindings = (text, lineOffset) =>
|
||||
scanCssTextForPseudoStripe(text).map(hit =>
|
||||
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
|
||||
|
||||
if (cssLike.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
|
||||
// Block-level CSS checks that need multiple declarations must run over the
|
||||
// complete source, not line-by-line. This covers standalone stylesheets,
|
||||
@@ -678,6 +704,7 @@ function detectText(content, filePath, options = {}) {
|
||||
// reported every selector one line low. runRegexMatchers keeps startLine - 1
|
||||
// because it indexes its split lines from zero.
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
@@ -696,6 +723,7 @@ function detectText(content, filePath, options = {}) {
|
||||
phase: 'css-in-js',
|
||||
}));
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
|
||||
import { collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
|
||||
import { CSS_NAMED_COLORS, collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsdom CSS-variable border override map
|
||||
@@ -344,18 +344,29 @@ const STATIC_PROP_MAP = {
|
||||
'overflow-y': 'overflowY',
|
||||
};
|
||||
|
||||
// parseStaticColor tries parseAnyColor first, which already resolves every
|
||||
// name in the shared CSS_NAMED_COLORS table. This fallback only carries the
|
||||
// keywords parseAnyColor deliberately returns null for: the cascade needs
|
||||
// `transparent` to read as an actual zero-alpha color.
|
||||
const STATIC_NAMED_COLORS = {
|
||||
black: { r: 0, g: 0, b: 0, a: 1 },
|
||||
white: { r: 255, g: 255, b: 255, a: 1 },
|
||||
transparent: { r: 0, g: 0, b: 0, a: 0 },
|
||||
gray: { r: 128, g: 128, b: 128, a: 1 },
|
||||
grey: { r: 128, g: 128, b: 128, a: 1 },
|
||||
silver: { r: 192, g: 192, b: 192, a: 1 },
|
||||
red: { r: 255, g: 0, b: 0, a: 1 },
|
||||
green: { r: 0, g: 128, b: 0, a: 1 },
|
||||
blue: { r: 0, g: 0, b: 255, a: 1 },
|
||||
};
|
||||
|
||||
// Named-color alternation for plucking a color token out of shorthand values
|
||||
// (issue #359: a hardcoded 9-name list here silently dropped `purple`,
|
||||
// `crimson`, `teal`, ... from border shorthands, so the side defaulted to
|
||||
// neutral black and side-tab never fired on .html files). Derived from the
|
||||
// same table parseAnyColor resolves against, so extraction and parsing can't
|
||||
// drift apart. Longest-first so names containing other names as substrings
|
||||
// (rebeccapurple) are matched whole.
|
||||
const NAMED_COLOR_TOKENS = [...Object.keys(CSS_NAMED_COLORS), ...Object.keys(STATIC_NAMED_COLORS)]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.join('|');
|
||||
const STATIC_COLOR_TOKEN_RE = new RegExp(
|
||||
`(?:rgba?\\([^)]+\\)|oklch\\([^)]+\\)|oklab\\([^)]+\\)|lch\\([^)]+\\)|lab\\([^)]+\\)|hsla?\\([^)]+\\)|hwb\\([^)]+\\)|#[0-9a-f]{3,8}\\b|\\b(?:${NAMED_COLOR_TOKENS})\\b)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
function splitCssList(value) {
|
||||
const parts = [];
|
||||
let depth = 0, quote = '', start = 0;
|
||||
@@ -441,7 +452,7 @@ function extractStaticColor(value) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
|
||||
const colorLike = raw.match(STATIC_COLOR_TOKEN_RE);
|
||||
if (!colorLike) return '';
|
||||
return colorLike[0];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
checkElementMotion,
|
||||
checkElementOversizedH1,
|
||||
checkElementQuality,
|
||||
checkElementRadialSpotlight,
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
@@ -105,6 +106,7 @@ const STATIC_ELEMENT_RULES = [
|
||||
{ id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
|
||||
{ id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
|
||||
{ id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) },
|
||||
{ id: 'radial-spotlight-glow', selector: '*', run: (el, tag, style, window) => checkElementRadialSpotlight(el, style, tag, window) },
|
||||
];
|
||||
|
||||
async function detectHtml(filePath, options = {}) {
|
||||
|
||||
@@ -6,7 +6,13 @@ function getAP(id) {
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
|
||||
@@ -5,11 +5,24 @@ import path from 'node:path';
|
||||
// File walker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hidden directories are skipped wholesale during recursion (below), which
|
||||
// covers .git / .next / .nuxt / .svelte-kit / .turbo / .vercel and — the
|
||||
// issue #303 class — every vendored AI-harness install (.claude, .cursor,
|
||||
// .codex, .agents, .impeccable, ...) whose bundled detector source would
|
||||
// otherwise be reported as findings on a root scan. Only the non-hidden
|
||||
// build/dependency dirs need naming. An explicitly passed hidden target
|
||||
// still scans: walkDir name-checks children, never the root it's given.
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
|
||||
'.svelte-kit', '__pycache__', '.turbo', '.vercel',
|
||||
'node_modules', 'dist', 'build', '__pycache__',
|
||||
]);
|
||||
|
||||
// The exceptions to the hidden-dir rule: hidden directories that
|
||||
// conventionally hold real UI source rather than tooling or vendored code.
|
||||
// VitePress and VuePress keep custom theme components in
|
||||
// .vitepress/theme/*.vue / .vuepress/theme/, and Storybook keeps preview
|
||||
// decorators/styles in .storybook/.
|
||||
const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
||||
|
||||
const SCANNABLE_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||
'.jsx', '.tsx', '.js', '.ts',
|
||||
@@ -24,6 +37,7 @@ function walkDir(dir) {
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
||||
|
||||
@@ -149,6 +149,15 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'radial-spotlight-glow',
|
||||
category: 'slop',
|
||||
name: 'Decorative radial spotlight glow',
|
||||
description:
|
||||
'A soft, low-opacity accent-colored radial gradient fading to transparent, dropped behind a hero or section as a "spotlight." It is a reflex AI decoration — the translucent cousin of the saturated radial halo. Let the surface stand on its own, or light the composition with a deliberate material accent rather than a floating colored haze.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'marquee',
|
||||
category: 'slop',
|
||||
@@ -213,9 +222,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
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.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -405,6 +419,14 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
@@ -556,6 +578,18 @@ function getAntipattern(id) {
|
||||
return ANTIPATTERNS.find(rule => rule.id === id);
|
||||
}
|
||||
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
return ADVISORY_RULE_IDS.has(id);
|
||||
}
|
||||
|
||||
function getRulesForCategory(category) {
|
||||
return ANTIPATTERNS.filter(rule => rule.category === category);
|
||||
}
|
||||
@@ -585,8 +619,10 @@ export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
ADVISORY_RULE_IDS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
isAdvisoryRule,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
+329
-12
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BORDER_SAFE_TAGS,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
EM_DASH_FLOOR,
|
||||
GENERIC_FONTS,
|
||||
KNOWN_SERIF_FONTS,
|
||||
OVERUSED_FONTS,
|
||||
@@ -107,9 +109,21 @@ function checkColors(opts) {
|
||||
const findings = [];
|
||||
|
||||
if (hasDirectText && textColor && !isEmojiOnly) {
|
||||
// Gradient-clipped text (`background-clip: text`, typically with a
|
||||
// transparent text-fill) paints its glyphs *with* the element's own
|
||||
// gradient. The `color` value the cascade still reports is never painted,
|
||||
// and the gradient is the fill, not a backdrop — so measuring `color`
|
||||
// against that gradient (which resolveGradientStops picks up as the
|
||||
// element's own background-image) is a guaranteed false positive
|
||||
// (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
|
||||
// rule below still flags the pattern itself. Skipping a rule beats a false
|
||||
// positive here — the true painted contrast can't be measured from `color`.
|
||||
const isGradientClippedText = bgClip === 'text';
|
||||
// Run background-dependent checks against either a solid bg or, if the
|
||||
// ancestor is a gradient, against every gradient stop (use the worst case).
|
||||
const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
|
||||
const bgs = isGradientClippedText
|
||||
? null
|
||||
: (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
|
||||
if (bgs) {
|
||||
// Gray on colored background — flag if every stop is chromatic
|
||||
const textLum = relativeLuminance(textColor);
|
||||
@@ -809,7 +823,13 @@ function isZeroOffset(value) {
|
||||
// never see it — pseudo-elements aren't part of the DOM the cascade walks —
|
||||
// so this scans stylesheet text directly, mirroring the border rule's
|
||||
// gates: >= 3px thick, chromatic fill, full height against a side edge.
|
||||
function scanCssTextForPseudoStripe(content) {
|
||||
function scanCssTextForPseudoStripe(rawContent) {
|
||||
// Blank comment bodies byte-for-byte so commented-out rules are not
|
||||
// scanned as live CSS and every rule keeps its source offset (each
|
||||
// finding carries `index` so line-based callers can attribute it and
|
||||
// line-scoped inline ignores can match).
|
||||
const content = String(rawContent || '').replace(/\/\*[\s\S]*?\*\//g,
|
||||
(block) => block.replace(/[^\n]/g, ' '));
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const findings = [];
|
||||
const seen = new Set();
|
||||
@@ -918,9 +938,13 @@ function scanCssTextForPseudoStripe(content) {
|
||||
|
||||
if (seen.has(selector)) continue;
|
||||
seen.add(selector);
|
||||
// The selector group absorbs whitespace trailing the previous rule;
|
||||
// advance past it so `index` points at the selector itself.
|
||||
const selectorStart = m.index + (m[1].length - m[1].trimStart().length);
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
index: selectorStart,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
@@ -1667,29 +1691,54 @@ function resolveBackground(el, win, customPropMap) {
|
||||
// Walk parents looking for a gradient background and return its color stops.
|
||||
// Used as a fallback when resolveBackground() returns null because the
|
||||
// effective background is a gradient (no single solid color to compare against).
|
||||
function resolveGradientStops(el, win) {
|
||||
function resolveGradientStops(el, win, customPropMap) {
|
||||
let current = el;
|
||||
while (current && current.nodeType === 1) {
|
||||
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||
const bgImage = style.backgroundImage || '';
|
||||
let stops = null;
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
const stops = parseGradientColors(bgImage);
|
||||
if (stops.length > 0) return stops;
|
||||
const parsed = parseGradientColors(bgImage);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
if (!DETECTOR_IS_BROWSER) {
|
||||
if (!stops && !DETECTOR_IS_BROWSER) {
|
||||
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||
const stops = parseGradientColors(bgMatch[1]);
|
||||
if (stops.length > 0) return stops;
|
||||
const parsed = parseGradientColors(bgMatch[1]);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
}
|
||||
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A translucent gradient stop (e.g. a faint `rgba(52,192,168,0.09)` accent
|
||||
// glow) paints over whatever surface sits beneath the gradient — the browser
|
||||
// composites it, so its effective color is far closer to the base than to the
|
||||
// full-opacity accent. Treating the stop as opaque flags every text child of a
|
||||
// softly-glowing section as low-contrast (issue #409 Case B). Composite each
|
||||
// alpha stop over the resolved surface beneath the gradient element. When that
|
||||
// surface isn't resolvable (another gradient above, no opaque ancestor), drop
|
||||
// the translucent stop rather than guess: a dropped stop can't manufacture a
|
||||
// false finding, and skipping beats a wrong ratio.
|
||||
function compositeGradientStops(stops, gradientEl, win, customPropMap) {
|
||||
const hasAlpha = stops.some(s => (s.a ?? 1) < 0.99);
|
||||
if (!hasAlpha) return stops;
|
||||
const base = resolveBackground(gradientEl.parentElement || gradientEl, win, customPropMap);
|
||||
const out = [];
|
||||
for (const s of stops) {
|
||||
const a = s.a ?? 1;
|
||||
if (a >= 0.99) { out.push(s); continue; }
|
||||
if (base) out.push(compositeColorOver(s, base));
|
||||
// else: unresolvable base — drop the translucent stop (skip, don't guess).
|
||||
}
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
// Parse a single CSS length token to pixels. Accepts "12px", "50%", a
|
||||
// shorthand like "12px 4px" (uses the first value), or empty / null.
|
||||
// Returns the pixel value, or null when the input is unparseable.
|
||||
@@ -2594,6 +2643,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -2700,6 +2776,131 @@ function checkElementAIPaletteDOM(el) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── Decorative radial spotlight glow ───────────────────────────────────────
|
||||
// A soft, low-opacity chromatic radial-gradient fading to transparent, painted
|
||||
// as a decorative wash behind a hero or section. The translucent sibling of the
|
||||
// `radial-halo` tell: `radial-halo` requires a saturated, near-opaque center on
|
||||
// a dark page; this catches the low-alpha "spotlight" the halo gate lets slip
|
||||
// (e.g. `radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26),
|
||||
// transparent 44%)`). The two alpha bands are disjoint, so they never
|
||||
// double-report the same declaration.
|
||||
const SPOTLIGHT_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i;
|
||||
|
||||
// Parse the FIRST non-repeating radial-gradient in a background value into its
|
||||
// ordered color stops. Each stop is { color: {r,g,b,a} | null, transparent }.
|
||||
// Returns null when there is no plain radial-gradient to read.
|
||||
function parseRadialGradientStops(value) {
|
||||
if (!value || !/radial-gradient/i.test(value)) return null;
|
||||
const gradRe = /(repeating-)?radial-gradient\(/gi;
|
||||
let g;
|
||||
while ((g = gradRe.exec(value)) !== null) {
|
||||
if (g[1]) continue; // repeating-* is a pattern, not a spotlight
|
||||
let depth = 0, end = -1;
|
||||
const open = value.indexOf('(', g.index);
|
||||
for (let i = open; i < value.length; i++) {
|
||||
if (value[i] === '(') depth++;
|
||||
else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end < 0) return null;
|
||||
const args = splitTopLevelCommas(value.slice(open + 1, end));
|
||||
// The optional prelude (shape / size / `at <pos>`) carries no color token.
|
||||
const stopArgs = args.filter(a => SPOTLIGHT_COLOR_TOKEN_RE.test(a));
|
||||
if (stopArgs.length < 2) return null;
|
||||
return stopArgs.map(a => {
|
||||
const tok = a.match(SPOTLIGHT_COLOR_TOKEN_RE);
|
||||
if (!tok) return { color: null, transparent: false };
|
||||
if (/^transparent$/i.test(tok[0])) return { color: null, transparent: true };
|
||||
const color = parseAnyColor(tok[0]);
|
||||
return { color, transparent: !!color && (color.a ?? 1) <= 0.05 };
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pure gate. `label` is a stable identifier the fixture test keys on.
|
||||
function checkRadialSpotlight({ gradientValue, width, height, label }) {
|
||||
const stops = parseRadialGradientStops(gradientValue);
|
||||
if (!stops || stops.length < 2) return [];
|
||||
|
||||
// Must fade OUT: the last stop is transparent / near-zero alpha. A gradient
|
||||
// between two visible surfaces is a real background, not a floating glow.
|
||||
const last = stops[stops.length - 1];
|
||||
const lastAlpha = last.transparent ? 0 : (last.color ? (last.color.a ?? 1) : 1);
|
||||
if (lastAlpha > 0.05) return [];
|
||||
|
||||
// The visible (non-transparent, parseable) color stops.
|
||||
const colored = stops.filter(s => !s.transparent && s.color && (s.color.a ?? 1) > 0.05);
|
||||
if (colored.length === 0) return [];
|
||||
// One soft glow, not a multi-color composition: at most two visible stops.
|
||||
if (colored.length > 2) return [];
|
||||
// Every visible stop must be LOW opacity. Any opaque stop means a real fill
|
||||
// or a saturated halo (`radial-halo`'s job), not this translucent spotlight.
|
||||
if (colored.some(s => (s.color.a ?? 1) >= 0.45)) return [];
|
||||
// At least one visible stop must be chromatic. A neutral (grayscale)
|
||||
// near-black / near-white vignette is a legitimate lighting move, exempt.
|
||||
const chromatic = colored.find(s => hasChroma(s.color, 24));
|
||||
if (!chromatic) return [];
|
||||
|
||||
// Decorative-scale gate. Badges, avatars, and actual small "lights" are
|
||||
// exempt; a spotlight glow only reads as slop when it washes a large surface.
|
||||
if (!(width >= 240 && height >= 160)) return [];
|
||||
|
||||
const alpha = (chromatic.color.a ?? 1).toFixed(2);
|
||||
const name = label || 'section';
|
||||
return [{
|
||||
id: 'radial-spotlight-glow',
|
||||
snippet: `radial-gradient spotlight glow "${name}" (${colorToHex(chromatic.color)} a${alpha} → transparent) on ${Math.round(width)}x${Math.round(height)} surface`,
|
||||
}];
|
||||
}
|
||||
|
||||
// Read the raw radial-gradient source off an element's computed style, with a
|
||||
// fallback to the `background` shorthand and the inline style attribute for
|
||||
// engines that don't decompose the shorthand into backgroundImage.
|
||||
function elementGradientValue(style, el) {
|
||||
const bgImage = style.backgroundImage && style.backgroundImage !== 'none' ? style.backgroundImage : '';
|
||||
if (/radial-gradient/i.test(bgImage)) return bgImage;
|
||||
const bg = style.background || '';
|
||||
if (/radial-gradient/i.test(bg)) return bg;
|
||||
const rawStyle = el?.getAttribute?.('style') || '';
|
||||
const m = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (m && /radial-gradient/i.test(m[1])) return m[1];
|
||||
return '';
|
||||
}
|
||||
|
||||
function spotlightLabel(el) {
|
||||
const dataName = el.getAttribute?.('data-name');
|
||||
if (dataName) return dataName;
|
||||
if (typeof el.id === 'string' && el.id) return el.id;
|
||||
const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/)[0] : '';
|
||||
if (cls) return cls;
|
||||
return el.tagName ? el.tagName.toLowerCase() : 'section';
|
||||
}
|
||||
|
||||
function checkElementRadialSpotlightDOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
const gradientValue = elementGradientValue(style, el);
|
||||
if (!gradientValue) return [];
|
||||
const rect = el.getBoundingClientRect();
|
||||
return checkRadialSpotlight({
|
||||
gradientValue,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
label: spotlightLabel(el),
|
||||
});
|
||||
}
|
||||
|
||||
function checkElementRadialSpotlight(el, style, tag, window) {
|
||||
const gradientValue = elementGradientValue(style, el);
|
||||
if (!gradientValue) return [];
|
||||
// Static engine does no layout — read explicit pixel dimensions from CSS.
|
||||
return checkRadialSpotlight({
|
||||
gradientValue,
|
||||
width: parseFloat(style.width) || 0,
|
||||
height: parseFloat(style.height) || 0,
|
||||
label: spotlightLabel(el),
|
||||
});
|
||||
}
|
||||
|
||||
const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
|
||||
|
||||
// Resolve a CSS font-size value to pixels by walking up the parent chain.
|
||||
@@ -2821,6 +3022,55 @@ function textDescendantsFlushSides(el, rect) {
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Screen-reader-only ("visually hidden") text is exempt from the tiny-text
|
||||
// floors: it is never rendered, so its size is irrelevant. Detect the two
|
||||
// standard idioms — a known sr-only class on the element or an ancestor, and
|
||||
// the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
|
||||
// browser (computed styles).
|
||||
const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]';
|
||||
function isVisuallyHidden(el, style) {
|
||||
if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
|
||||
const pos = style.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
const clip = style.clip || '';
|
||||
const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
|
||||
if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
|
||||
const w = parseFloat(style.width);
|
||||
const h = parseFloat(style.height);
|
||||
const overflow = style.overflow || '';
|
||||
if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Elements whose text is never painted: document metadata and script/style
|
||||
// payloads. Their JS / CSS / JSON-LD text satisfies `hasDirectText`, and on
|
||||
// sites that set `html { font-size: 62.5% }` their inherited computed size is
|
||||
// 10px — so the text-size floors flag them as tiny body copy even though
|
||||
// nothing renders (issue #408: dozens of phantom "10px body text" findings on
|
||||
// every Shopify page). Exclude them, plus anything the cascade resolves to
|
||||
// display:none / visibility:hidden. The jsdom path can't lay out, so the
|
||||
// tag/attribute-based exclusions carry the weight there; the display checks are
|
||||
// computed-style reads that resolve without layout in both adapters.
|
||||
const NON_RENDERED_TAGS = new Set([
|
||||
'script', 'style', 'title', 'noscript', 'template', 'head',
|
||||
'meta', 'link', 'base', 'param', 'source', 'track', 'datalist',
|
||||
'col', 'colgroup', 'map', 'area',
|
||||
]);
|
||||
function isNonRenderedText(el, tag, style) {
|
||||
const t = (tag || '').toLowerCase();
|
||||
if (NON_RENDERED_TAGS.has(t)) return true;
|
||||
// Descendants of <head> never render even when the tag itself would
|
||||
// (some sites nest <noscript>/<template> content there).
|
||||
if (el && el.closest && el.closest('head')) return true;
|
||||
if (style) {
|
||||
if (style.display === 'none') return true;
|
||||
const vis = style.visibility;
|
||||
if (vis === 'hidden' || vis === 'collapse') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -2831,8 +3081,13 @@ function textDescendantsFlushSides(el, rect) {
|
||||
function checkQuality(opts) {
|
||||
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
|
||||
const findings = [];
|
||||
// Skip browser extension injected elements
|
||||
const elId = el.id || '';
|
||||
// Skip browser extension injected elements. Read the id via getAttribute
|
||||
// whenever `el.id` is not a string: on a <form> (and other
|
||||
// [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
|
||||
// shadows the builtin `id` getter and returns the control element, whose
|
||||
// `.startsWith` is undefined and throws (issue #407 — every Shopify product
|
||||
// form ships an <input name="id">).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
||||
|
||||
// --- Line length too long --- (browser-only: needs rect.width)
|
||||
@@ -3100,11 +3355,67 @@ function checkQuality(opts) {
|
||||
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
|
||||
const isUppercase = style.textTransform === 'uppercase';
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase && !isNonRenderedText(el, tag, style)) {
|
||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undersized functional / UI text ---
|
||||
// Complements `tiny-text` above, which owns long body copy and deliberately
|
||||
// EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
|
||||
// uppercase micro-labels). This rule targets exactly that blind spot: the
|
||||
// interactive and short content-bearing text — nav items, buttons, labels,
|
||||
// table cells, meta rows, timecodes — shipped below an 11px floor.
|
||||
//
|
||||
// The live failure it closes: a build shipped its entire furniture layer at
|
||||
// 8px, and the design hook waved it through because 8px had been added to
|
||||
// the DESIGN.md size ramp. Being on the ramp is a token argument, not a
|
||||
// legibility one, so this rule ignores the design system entirely — a value
|
||||
// on the ramp is still flagged.
|
||||
//
|
||||
// Floors: 11px for anything functional. The floor holds inside a footer;
|
||||
// only NON-interactive legal smallprint gets the softer 10px floor. Exempts
|
||||
// sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
|
||||
// Uppercase letterspaced micro-labels are still functional — not exempt.
|
||||
{
|
||||
const directText = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent || '')
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const dtLen = directText.length;
|
||||
// `option` renders (in native select popups) so it stays a local skip;
|
||||
// script/style/title/noscript/head-descendants and display:none /
|
||||
// visibility:hidden are handled by isNonRenderedText (shared with tiny-text).
|
||||
const UI_SKIP_TAGS = new Set(['sub', 'sup', 'option']);
|
||||
// jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
|
||||
// text that computes at or above the floor never reaches here. The browser
|
||||
// adapter additionally catches values only resolvable with real layout
|
||||
// (e.g. viewport-relative units, cascade winners set in linked sheets).
|
||||
if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !isNonRenderedText(el, tag, style)) {
|
||||
const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]';
|
||||
const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
|
||||
if (!isExemptContext && !isVisuallyHidden(el, style)) {
|
||||
const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]';
|
||||
const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]';
|
||||
const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]';
|
||||
const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
|
||||
const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
|
||||
const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
|
||||
const floor = (!isInteractive && isSmallprint) ? 10 : 11;
|
||||
// Fire on functional text only: interactive, structural furniture, or
|
||||
// any short (<=20-char) run — the label / meta / timecode shape. Long
|
||||
// non-furniture body copy stays with `tiny-text`, so the two rules
|
||||
// never double-flag the same element.
|
||||
if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
|
||||
const excerpt = directText.slice(0, 40);
|
||||
findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- All-caps body text ---
|
||||
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
||||
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
@@ -3295,7 +3606,7 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
||||
textColor,
|
||||
bgColor: ownBg,
|
||||
effectiveBg: finalEffectiveBg,
|
||||
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window),
|
||||
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
@@ -5068,6 +5379,7 @@ function checkFirstViewportColumnOverflowDOM() {
|
||||
}
|
||||
|
||||
export {
|
||||
CSS_NAMED_COLORS,
|
||||
checkBorders,
|
||||
isEmojiOnlyText,
|
||||
checkColors,
|
||||
@@ -5121,6 +5433,8 @@ export {
|
||||
checkNumberedSectionLabels,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkNumberedSectionLabelsDOM,
|
||||
checkEmDashOveruse,
|
||||
checkEmDashOveruseDOM,
|
||||
isRepeatedTextContainer,
|
||||
collectRepeatedContainerTextFindings,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
@@ -5129,6 +5443,9 @@ export {
|
||||
checkElementMotionDOM,
|
||||
checkElementGlowDOM,
|
||||
checkElementAIPaletteDOM,
|
||||
checkElementRadialSpotlightDOM,
|
||||
checkElementRadialSpotlight,
|
||||
checkRadialSpotlight,
|
||||
resolveFontSizePx,
|
||||
resolveLengthPx,
|
||||
checkQuality,
|
||||
|
||||
@@ -68,6 +68,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -97,5 +106,7 @@ export {
|
||||
GENERIC_FONTS,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
KNOWN_SERIF_FONTS,
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
@@ -71,6 +71,11 @@ function cloneRawDetectionConfig() {
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// Advisory rules are opt-in for the design hook; the CLI carries the setting
|
||||
// so config round-trips (e.g. `impeccable hooks ignore-value`) preserve it.
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -151,6 +156,9 @@ function normalizeDetectionConfigForWrite(config) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.advisoryRules === 'include' || config?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = config.advisoryRules;
|
||||
}
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
|
||||
Reference in New Issue
Block a user