Compare commits

..
Author SHA1 Message Date
d5403f9d65 Bump extension to v1.2.1 and add consolidated changelog (#265)
Release bump covering the recent extension fixes that ship together:
toolbar badge count parity (#262), local file:// scan failure messaging
(#258), and the Kinpaku popup theme (#260).

Merge after #264, #261, and #263.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:51:15 -07:00
1f4021b16c Fix: make Chrome extension toolbar badge count anti-patterns (#264)
The toolbar badge counted flagged elements (state.findings.length) while
the popup and DevTools panel counted total anti-pattern findings, so the
same scan showed two numbers (e.g. 21 vs 34 on the design-system page).
Since the surfaces are labeled "anti-patterns", count total findings in
the badge too so all three agree.

Closes #262

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:50:36 -07:00
046a8593f5 Fix: surface scan failures in extension popup for local files (#261)
* Fix: surface scan failures in extension popup for local files

Scanning a local file:// page with "Allow access to file URLs" off left
the popup stuck on "Scanning..." because the blocked content-script
injection returned silently. ensureContentScriptInjected() now returns the
real error, and sendScanToTab() sends a scan-failed message that the popup
renders as a small line, with a permission hint shown only for file:// tabs.

Fixes #258

Co-authored-by: Cursor <cursoragent@cursor.com>

* Improve: report the actual error when a non-file scan fails

The generic "This page can't be scanned." gave no reason. Non-file failures
now read "Couldn't scan this page: <error>" so the user sees what Chrome
reported instead of a dead end.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: scope popup broadcasts to the active tab

The popup acted on every findings-updated / scan-failed / overlays broadcast
regardless of which tab it targeted, so a background or DevTools-driven
rescan on another tab could reset the button or show a spurious error. Cache
the active tab id and ignore broadcasts for other tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:49:16 -07:00
Abdul WahabandGitHub e371c99f08 Update extension popup to Kinpaku theme (#263)
* Update extension popup to Kinpaku theme

* Fix popup light mode contrast
2026-06-18 21:48:37 -07:00
github-actions[bot] d949abd180 Sync generated provider output 2026-06-19 01:47:38 +00:00
Abdul WahabandGitHub 07667ed08f Add quiet mode to detect CLI (#259) 2026-06-18 18:47:02 -07:00
Paul Bakaus 1c897a09c8 Polish docs page 2026-06-17 17:49:38 +09:00
Paul Bakaus 617b3a6e5e Polish live mode and slop pages 2026-06-17 17:38:07 +09:00
Paul Bakaus c7539c867d Fix live picker sizing and divider detection 2026-06-17 13:10:53 +09:00
Paul Bakaus 4f50db2bca Fix live picker steer sizing 2026-06-17 12:36:58 +09:00
github-actions[bot] f726894373 Sync generated provider output 2026-06-17 03:00:20 +00:00
Paul BakausandGitHub 99a284a0d9 Fix live page editable focus handling (#256) 2026-06-16 19:59:48 -07:00
github-actions[bot] b86f2cc353 Sync generated provider output 2026-06-17 02:51:20 +00:00
Paul BakausandGitHub 8b0c895703 [codex] Fix CLI skill update detection (#257)
* Fix CLI skill update detection

* Preserve linked skills during install refresh

* Keep existing installs working offline

* Respect provider scope during install refresh
2026-06-16 19:50:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1268f10b76 chore(deps): bump actions/cache from 4 to 5 in the github-actions group (#249)
Bumps the github-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 06:56:23 -07:00
Paul BakausandClaude Opus 4.8 795e8ed5e5 fix(skill): bundle detector config dependency so critique runs (#254)
The bundled detector's cli/main.mjs imports ../../lib/impeccable-config.mjs,
which in the source CLI resolves to cli/lib/impeccable-config.mjs. The skill
build only copies cli/engine/** into scripts/detector/**, leaving that
dependency behind, so from the bundled scripts/detector/cli/main.mjs the same
import resolved to scripts/lib/impeccable-config.mjs and failed with
"Cannot find module .../lib/impeccable-config.mjs". /impeccable critique (and
any detector-backed command) crashed on startup for every provider since #252.

Teach the detector bundler to copy out-of-bundle engine dependencies into the
skill's scripts/lib/, and add a build test that walks every bundled script and
asserts each relative import resolves to another bundled file, so a future
out-of-bundle dependency fails the test instead of the user.

Skill v3.7.1 (patch). CLI unchanged — the engine resolves fine in the CLI's
own tree; only the skill bundling was wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:49:31 +09:00
98 changed files with 10755 additions and 619 deletions
+1
View File
@@ -1,6 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.1
---
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -12,7 +12,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.7.0",
"version": "3.7.1",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.7.0",
"version": "3.7.1",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
license: Apache 2.0
---
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
---
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+8 -8
View File
@@ -164,7 +164,7 @@ jobs:
bun-version: latest
- name: Cache fixture npm downloads
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -172,7 +172,7 @@ jobs:
${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -238,7 +238,7 @@ jobs:
bun-version: latest
- name: Cache fixture npm downloads
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -246,7 +246,7 @@ jobs:
${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -307,7 +307,7 @@ jobs:
- name: Cache fixture npm downloads
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -316,7 +316,7 @@ jobs:
- name: Cache Playwright Chromium
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -371,7 +371,7 @@ jobs:
- name: Cache fixture npm downloads
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -380,7 +380,7 @@ jobs:
- name: Cache Playwright Chromium
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
license: Apache 2.0
---
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+90 -15
View File
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
license: Apache 2.0
allowed-tools:
- Bash(npx impeccable *)
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+90 -15
View File
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+90 -15
View File
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+112 -58
View File
@@ -421,19 +421,20 @@ function getSkillsVersion(root) {
}
/**
* Hash all SKILL.md files in a directory tree for comparison.
* Returns a sorted string of "name:hash" pairs.
* Return every file in a directory tree, sorted and relative to the tree root.
*/
function hashSkillsDir(skillsDir) {
if (!existsSync(skillsDir)) return '';
const entries = [];
for (const name of readdirSync(skillsDir).sort()) {
const skillMd = join(skillsDir, name, 'SKILL.md');
if (!existsSync(skillMd)) continue;
const hash = createHash('sha256').update(readFileSync(skillMd)).digest('hex').slice(0, 12);
entries.push(`${name}:${hash}`);
function listSkillTreeFiles(root, dir = root) {
if (!existsSync(dir)) return [];
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...listSkillTreeFiles(root, full));
} else if (entry.isFile()) {
files.push(relative(root, full).split(sep).join('/'));
}
}
return entries.join(',');
return files;
}
/**
@@ -507,11 +508,18 @@ async function copyOrExtractLocalBundle(sourceValue) {
* provider-specific paths. Different install methods (npx skills add
* vs our bundle) resolve {{scripts_path}} to different provider dirs
* (e.g. .agents vs .claude), so we strip those differences.
* Version fields intentionally remain part of the comparison so metadata-only
* releases still refresh installed files.
*/
function normalizeForHash(content) {
return content
.replace(/\.(claude|cursor|agents|github|gemini|codex|kiro|opencode|pi|qoder|trae|trae-cn|rovodev)\/skills\//g, '.PROVIDER/skills/')
.replace(/^version:\s*.+$/m, 'version: NORMALIZED');
.replace(/\.(claude|cursor|agents|github|gemini|codex|kiro|opencode|pi|qoder|trae|trae-cn|rovodev)\/skills\//g, '.PROVIDER/skills/');
}
function hashSkillFile(filePath) {
return createHash('sha256')
.update(normalizeForHash(readFileSync(filePath, 'utf-8')))
.digest('hex');
}
/**
@@ -536,10 +544,10 @@ function deduplicateProviders(root, providers) {
/**
* Compare local skills against a downloaded bundle.
* Only checks skills that exist in the bundle (ignores user's custom
* skills that aren't part of impeccable). Deduplicates providers that
* share the same real path (symlinks). Normalizes provider-specific
* paths and version fields before comparing.
* Only checks skills that exist in the bundle (ignores user's custom skills
* that aren't part of impeccable). Deduplicates providers that share the same
* real path (symlinks). Compares the full bundled skill tree, not just
* SKILL.md, so script-only fixes and removed files are detected.
* Returns true if every bundle skill matches the local copy.
*/
function isUpToDate(root, providers, bundleDir) {
@@ -551,14 +559,21 @@ function isUpToDate(root, providers, bundleDir) {
if (!existsSync(bundleSkillsDir)) continue;
for (const name of readdirSync(bundleSkillsDir)) {
const bundleMd = join(bundleSkillsDir, name, 'SKILL.md');
const localMd = join(localSkillsDir, name, 'SKILL.md');
const bundleSkillDir = join(bundleSkillsDir, name);
const localSkillDir = join(localSkillsDir, name);
const bundleMd = join(bundleSkillDir, 'SKILL.md');
if (!existsSync(bundleMd)) continue;
if (!existsSync(localMd)) return false;
if (!existsSync(localSkillDir)) return false;
const bundleHash = createHash('sha256').update(normalizeForHash(readFileSync(bundleMd, 'utf-8'))).digest('hex');
const localHash = createHash('sha256').update(normalizeForHash(readFileSync(localMd, 'utf-8'))).digest('hex');
if (bundleHash !== localHash) return false;
const bundleFiles = listSkillTreeFiles(bundleSkillDir);
const localFiles = listSkillTreeFiles(localSkillDir);
if (bundleFiles.join('\n') !== localFiles.join('\n')) return false;
for (const relPath of bundleFiles) {
const bundleHash = hashSkillFile(join(bundleSkillDir, ...relPath.split('/')));
const localHash = hashSkillFile(join(localSkillDir, ...relPath.split('/')));
if (bundleHash !== localHash) return false;
}
}
}
return true;
@@ -1019,6 +1034,26 @@ function copyProviderSkills(bundleDir, root, targets) {
return written;
}
function refreshProviderSkills(bundleDir, root, providers) {
const unique = deduplicateProviders(root, providers);
let updated = 0;
for (const { provider, localSkillsDir } of unique) {
const srcDir = join(bundleDir, provider, 'skills');
if (!existsSync(srcDir)) continue;
const skills = readdirSync(srcDir, { withFileTypes: true });
for (const skill of skills) {
if (!skill.isDirectory()) continue;
const src = join(srcDir, skill.name);
const dest = join(localSkillsDir, skill.name);
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
copyDirSync(src, dest);
updated++;
}
}
return updated;
}
function hookArtifactsForProvider(bundleDir, root, provider) {
return (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ sourceProvider, rel, destProvider, destRel }) => {
const writeRel = destRel || rel;
@@ -1455,25 +1490,62 @@ async function install(flags) {
if (existing && !force) {
console.log(`Impeccable skills are already installed (found in ${existing}/).`);
const installedTargets = findInstalledProviders(installRoot);
const hookTargets = targets.filter(provider => installedTargets.includes(provider));
const selectedInstalledTargets = targets.filter(provider => installedTargets.includes(provider));
const linkedTargets = findLinkedProviders(installRoot, selectedInstalledTargets);
const copyTargets = selectedInstalledTargets.filter(provider => !linkedTargets.includes(provider));
const hookTargets = selectedInstalledTargets;
const wantHooks = installHooks && await decideHookInstall(hookRoot, hookTargets, { yes });
const missingHookTargets = wantHooks
? hookTargets.filter(provider => !hookInstalledForProvider(hookRoot, provider))
: [];
if (missingHookTargets.length > 0) {
let bundleDir;
try {
bundleDir = await downloadAndExtractBundle();
const writtenHookTargets = copyProviderHooks(bundleDir, hookRoot, missingHookTargets, { skillRoot: installRoot });
if (writtenHookTargets.length > 0) console.log(`Installed hooks into: ${writtenHookTargets.join(', ')}`);
} catch (e) {
console.error(`Hook install failed: ${e.message}`);
process.exit(1);
} finally {
if (bundleDir) rmSync(bundleDir, { recursive: true, force: true });
let bundleDir;
try {
if (linkedTargets.length > 0) {
console.log(`Linked skills found in: ${linkedTargets.join(', ')}`);
console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable link --source=.impeccable` if new skills are added.');
if (copyTargets.length > 0) console.log(`Continuing with copied installs in: ${copyTargets.join(', ')}\n`);
}
let updated = 0;
const missingHookTargets = wantHooks
? hookTargets.filter(provider => !hookInstalledForProvider(hookRoot, provider))
: [];
let updateCheckSkipped = false;
if (copyTargets.length > 0 || missingHookTargets.length > 0) {
try {
bundleDir = await downloadAndExtractBundle();
} catch (e) {
if (missingHookTargets.length > 0) throw e;
updateCheckSkipped = true;
console.log(`Could not check for skill updates: ${e.message}`);
}
}
if (!updateCheckSkipped && copyTargets.length > 0 && !isUpToDate(installRoot, copyTargets, bundleDir)) {
migrateUnprefixImpeccable(installRoot);
updated = refreshProviderSkills(bundleDir, installRoot, copyTargets);
const v = getSkillsVersion(installRoot);
console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
}
const writtenHookTargets = missingHookTargets.length > 0
? copyProviderHooks(bundleDir, hookRoot, missingHookTargets, { skillRoot: installRoot })
: [];
if (writtenHookTargets.length > 0) console.log(`Installed hooks into: ${writtenHookTargets.join(', ')}`);
if (updateCheckSkipped) {
console.log('Existing skills were left unchanged.');
console.log('Run with --force to reinstall.\n');
} else if (updated === 0 && writtenHookTargets.length === 0) {
const v = getSkillsVersion(installRoot);
console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
console.log('Run with --force to reinstall.\n');
} else {
console.log('Done!\n');
}
} catch (e) {
console.error(`Install check failed: ${e.message}`);
process.exit(1);
} finally {
if (bundleDir) rmSync(bundleDir, { recursive: true, force: true });
}
console.log('Run with --force to reinstall.\n');
process.exit(0);
}
@@ -1675,25 +1747,7 @@ async function update(flags = []) {
const migrated = migrateUnprefixImpeccable(root);
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
// Copy from the bundle to each unique provider folder.
// Deduplicate so symlinked dirs (e.g. .claude/skills -> .agents/skills)
// are only written once with the correct provider's content.
const unique = deduplicateProviders(root, copyProviders);
let updated = 0;
for (const { provider, localSkillsDir } of unique) {
const srcDir = join(tmpDir, provider, 'skills');
if (!existsSync(srcDir)) continue;
const skills = readdirSync(srcDir, { withFileTypes: true });
for (const skill of skills) {
if (!skill.isDirectory()) continue;
const src = join(srcDir, skill.name);
const dest = join(localSkillsDir, skill.name);
if (existsSync(dest)) rmSync(dest, { recursive: true });
copyDirSync(src, dest);
updated++;
}
}
const updated = refreshProviderSkills(tmpDir, root, copyProviders);
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
+11 -4
View File
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
+5 -3
View File
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
+22 -7
View File
@@ -20,7 +20,9 @@ function getState(tabId) {
function updateBadge(tabId) {
const state = tabState.get(tabId);
const count = state?.findings?.length || 0;
// Count total anti-pattern findings (an element may carry several), matching
// the popup and DevTools panel rather than the flagged-element count.
const count = state?.findings?.reduce((sum, f) => sum + (f.findings?.length || 0), 0) || 0;
const text = count > 0 ? String(count) : '';
chrome.action.setBadgeText({ text, tabId }).catch(() => {});
chrome.action.setBadgeBackgroundColor({ color: '#d6336c', tabId }).catch(() => {});
@@ -58,7 +60,7 @@ async function buildScanConfig() {
// engages with the extension (DevTools panel/sidebar opened, popup scan, etc).
async function ensureContentScriptInjected(tabId) {
const state = getState(tabId);
if (state.csInjected) return true;
if (state.csInjected) return { ok: true };
try {
await chrome.scripting.executeScript({
target: { tabId },
@@ -66,16 +68,29 @@ async function ensureContentScriptInjected(tabId) {
injectImmediately: true,
});
state.csInjected = true;
return true;
return { ok: true };
} catch (err) {
// Common cause: chrome:// pages, the web store, or other restricted URLs
return false;
// Common cause: chrome:// pages, the web store, the Chrome Web Store, or
// file:// pages when "Allow access to file URLs" is off. Keep the real
// error so the UI can explain what happened.
return { ok: false, error: err?.message || String(err) };
}
}
async function sendScanToTab(tabId) {
const ok = await ensureContentScriptInjected(tabId);
if (!ok) return;
const { ok, error } = await ensureContentScriptInjected(tabId);
if (!ok) {
// Injection was blocked. Tell an open popup why so it can stop showing
// "Scanning..." and surface a hint. The popup may be closed, so ignore
// delivery failures.
let url = '';
try { url = (await chrome.tabs.get(tabId))?.url || ''; } catch { /* tab gone */ }
const message = url.startsWith('file:')
? 'Can\u2019t scan local files. Enable \u201CAllow access to file URLs\u201D for Impeccable in chrome://extensions.'
: `Couldn\u2019t scan this page${error ? `: ${error}` : '.'}`;
chrome.runtime.sendMessage({ action: 'scan-failed', tabId, message }).catch(() => {});
return;
}
const config = await buildScanConfig();
chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {});
}
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Impeccable",
"description": "Detect common UI anti-patterns in any web page",
"version": "1.2.0",
"version": "1.2.1",
"permissions": ["activeTab", "scripting", "storage", "webNavigation"],
"host_permissions": ["<all_urls>"],
"background": {
+105 -22
View File
@@ -4,10 +4,25 @@
box-sizing: border-box;
}
:root {
color-scheme: dark;
--ks-kinpaku: oklch(84% 0.19 80.46);
--ks-kinpaku-pale: oklch(86% 0.07 84);
--ks-patina: oklch(70% 0.12 188);
--ks-lacquer: oklch(7% 0.006 95);
--ks-lacquer-deep: oklch(4% 0.004 95);
--ks-text: oklch(88% 0 0);
--ks-text-muted: oklch(72% 0 0);
--ks-text-faint: oklch(62% 0 0);
--ks-rule: oklch(78% 0 0 / 0.16);
--popup-brand: var(--ks-kinpaku);
--ks-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
body {
width: 220px;
background: #1a1a1a;
color: #f5f3ef;
background: var(--ks-lacquer);
color: var(--ks-text);
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
padding: 16px;
@@ -16,19 +31,30 @@ body {
header {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
margin-bottom: 16px;
}
.logo {
font-size: 18px;
font-weight: 500;
opacity: 0.7;
width: 26px;
height: 26px;
display: grid;
place-items: center;
color: var(--popup-brand);
}
.logo svg {
width: 22px;
height: 22px;
display: block;
}
h1 {
font-size: 14px;
color: var(--popup-brand);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.count-display {
@@ -43,17 +69,17 @@ h1 {
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
color: #999;
color: var(--ks-text-faint);
transition: color 0.2s;
}
.count-number.has-findings {
color: oklch(55% 0.25 350);
color: var(--ks-kinpaku);
}
.count-label {
font-size: 12px;
color: #999;
color: var(--ks-text-muted);
}
.actions {
@@ -63,48 +89,105 @@ h1 {
margin-bottom: 16px;
}
.scan-error {
margin: -8px 0 16px;
font-size: 11px;
line-height: 1.4;
color: oklch(70% 0.13 30);
}
.scan-error[hidden] {
display: none;
}
.btn {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 6px;
border: 1px solid transparent;
border-radius: 2px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
transition:
transform 180ms var(--ks-ease),
background-color 180ms var(--ks-ease),
border-color 180ms var(--ks-ease),
color 180ms var(--ks-ease),
opacity 0.15s;
}
.btn-primary {
background: oklch(55% 0.25 350);
color: white;
background: var(--ks-kinpaku);
border-color: var(--ks-kinpaku);
color: var(--ks-lacquer-deep);
}
.btn-primary:hover {
background: oklch(50% 0.25 350);
background: var(--ks-kinpaku-pale);
border-color: var(--ks-kinpaku-pale);
transform: translateY(-1px);
}
.btn-secondary {
background: #333;
color: #ccc;
background: transparent;
border-color: var(--ks-kinpaku);
color: var(--ks-kinpaku);
}
.btn-secondary:hover {
background: #3a3a3a;
background: oklch(77% 0.14 82 / 0.08);
transform: translateY(-1px);
}
.btn:focus-visible {
outline: 2px solid var(--ks-patina);
outline-offset: 3px;
}
footer {
text-align: center;
padding-top: 8px;
border-top: 1px solid #333;
border-top: 1px solid var(--ks-rule);
}
footer a {
font-size: 11px;
color: #666;
color: var(--ks-text-muted);
text-decoration: none;
}
footer a:hover {
color: #999;
color: var(--popup-brand);
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--ks-kinpaku-deep: oklch(61% 0.085 78);
--ks-lacquer: oklch(97% 0.012 95);
--ks-text: oklch(25% 0.018 95);
--ks-text-muted: oklch(45% 0.015 95);
--ks-text-faint: oklch(55% 0.012 95);
--ks-rule: oklch(25% 0.02 95 / 0.12);
--popup-brand: oklch(18% 0.02 95);
}
.count-number.has-findings {
color: var(--ks-kinpaku-deep);
}
.btn-primary {
color: oklch(14% 0.018 95);
}
.btn-secondary,
.btn-secondary:hover {
border-color: var(--ks-kinpaku-deep);
color: var(--ks-kinpaku-deep);
}
.btn-secondary:hover {
background: oklch(77% 0.13 82 / 0.10);
}
}
+8 -1
View File
@@ -6,7 +6,12 @@
</head>
<body>
<header>
<span class="logo">/</span>
<span class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
<path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/>
</svg>
</span>
<h1>Impeccable</h1>
</header>
@@ -20,6 +25,8 @@
<button class="btn btn-secondary" id="btn-toggle">Hide overlays</button>
</div>
<p class="scan-error" id="scan-error" hidden></p>
<footer>
<a href="https://impeccable.style" id="link-site">impeccable.style</a>
</footer>
+17
View File
@@ -8,8 +8,13 @@ const countNumber = document.getElementById('count-number');
const countLabel = document.getElementById('count-label');
const btnScan = document.getElementById('btn-scan');
const btnToggle = document.getElementById('btn-toggle');
const scanError = document.getElementById('scan-error');
let overlaysVisible = true;
// The popup only ever reflects the active tab. Broadcasts from the service
// worker carry a tabId, so we cache the active one and ignore updates meant
// for other tabs (e.g. a DevTools-driven rescan failing on a background tab).
let activeTabId = null;
async function getActiveTabId() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
@@ -29,11 +34,14 @@ function updateFromState(state) {
async function loadState() {
const tabId = await getActiveTabId();
if (!tabId) return;
activeTabId = tabId;
chrome.runtime.sendMessage({ action: 'get-state', tabId }, updateFromState);
}
// Listen for real-time updates from service worker
chrome.runtime.onMessage.addListener((msg) => {
// Ignore broadcasts for a tab other than the one this popup is showing.
if (msg.tabId != null && activeTabId != null && msg.tabId !== activeTabId) return;
if (msg.action === 'findings-updated') {
const count = msg.findings?.reduce((sum, f) => sum + f.findings.length, 0) || 0;
countNumber.textContent = String(count);
@@ -41,6 +49,13 @@ chrome.runtime.onMessage.addListener((msg) => {
countLabel.textContent = count === 1 ? 'anti-pattern' : 'anti-patterns';
btnScan.textContent = 'Scan page';
btnScan.disabled = false;
scanError.hidden = true;
}
if (msg.action === 'scan-failed') {
btnScan.textContent = 'Scan page';
btnScan.disabled = false;
scanError.textContent = msg.message || 'Couldn\u2019t scan this page.';
scanError.hidden = false;
}
if (msg.action === 'overlays-toggled-broadcast') {
overlaysVisible = msg.visible;
@@ -51,6 +66,8 @@ chrome.runtime.onMessage.addListener((msg) => {
btnScan.addEventListener('click', async () => {
const tabId = await getActiveTabId();
if (!tabId) return;
activeTabId = tabId;
scanError.hidden = true;
btnScan.textContent = 'Scanning...';
btnScan.disabled = true;
chrome.runtime.sendMessage({ action: 'scan', tabId });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impeccable",
"version": "3.0.2",
"version": "3.0.3",
"author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.7.0",
"version": "3.7.1",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.7.1
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -22,6 +22,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,6 +90,7 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
@@ -118,6 +123,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --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
@@ -169,8 +175,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +206,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -258,6 +264,7 @@ async function detectCli() {
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
@@ -37,10 +38,11 @@ function shouldRunPageAnalyzers(content, filePath) {
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +59,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -0,0 +1,638 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
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 DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
-2
View File
@@ -44,7 +44,6 @@ export const PROVIDERS = {
configDir: '.codex',
displayName: 'Codex',
frontmatterFields: [],
includeVersion: false,
writeOpenAIMetadata: true,
// No agentFormat: the Codex subagent ships nested inside the skill's own
// agents/ folder (see CODEX_SKILL_PROVIDERS in factory.js), which Codex
@@ -60,7 +59,6 @@ export const PROVIDERS = {
displayName: 'Codex Repo Skills',
placeholderProvider: 'codex',
frontmatterFields: [],
includeVersion: false,
writeOpenAIMetadata: true,
},
github: {
+25
View File
@@ -11,6 +11,17 @@ export const PER_PROJECT_SCRIPT_ARTIFACTS = new Set(['config.json']);
const DETECTOR_BUNDLE_DIR = 'cli/engine';
// Detector source files that live OUTSIDE `cli/engine` but are imported by the
// bundled engine. `cli/engine/cli/main.mjs` imports `../../lib/impeccable-config.mjs`,
// which in the source CLI resolves to `cli/lib/impeccable-config.mjs`. The detector
// bundle copies `cli/engine/**` to `scripts/detector/**`, so from the bundled
// `scripts/detector/cli/main.mjs` that same `../../lib/...` import resolves to
// `scripts/lib/impeccable-config.mjs`. Copy the dependency there or the bundled
// detector fails at import time with "Cannot find module .../lib/impeccable-config.mjs".
const DETECTOR_EXTERNAL_DEPS = [
{ src: 'cli/lib/impeccable-config.mjs', dest: 'lib/impeccable-config.mjs' },
];
// Walk the harness-dir skill tree and return any per-project script
// artifacts found, ready for restoration after a full sync rm+recopy.
// Returns [{ relPath, content: Buffer }], where relPath is relative to
@@ -64,6 +75,20 @@ function readDetectorBundleScripts(rootDir) {
}
};
walk(detectorDir);
// Pull in engine dependencies that live outside the bundle dir so the
// generated detector is self-contained (see DETECTOR_EXTERNAL_DEPS).
for (const { src, dest } of DETECTOR_EXTERNAL_DEPS) {
const srcPath = path.join(rootDir, src);
if (!fs.existsSync(srcPath)) continue;
scripts.push({
name: dest,
content: fs.readFileSync(srcPath, 'utf-8'),
filePath: srcPath,
generated: true,
});
}
return scripts;
}
+27 -2
View File
@@ -23,8 +23,16 @@ import '../styles/changelog-faq-kinpaku.css';
<button type="button" class="cf-filter-btn" data-cf-filter="all" aria-pressed="false">All</button>
</div>
<article id="v3.7.0" class="cf-entry cf-entry--current">
<header class="cf-entry-head"><span class="cf-version">v3.7.0</span><span class="cf-date">June 16, 2026</span><span class="cf-current-badge">Current</span></header>
<article id="v3.7.1" class="cf-entry cf-entry--current">
<header class="cf-entry-head"><span class="cf-version">v3.7.1</span><span class="cf-date">June 16, 2026</span><span class="cf-current-badge">Current</span></header>
<p class="cf-entry-lead">A packaging fix so the bundled detector runs everywhere.</p>
<ul class="cf-items">
<li><strong><code>/impeccable critique</code> works again.</strong> The bundled detector was missing a config module it needed and crashed on startup. The build now ships that file with the skill, so critique and other detector-backed commands run cleanly.</li>
</ul>
</article>
<article id="v3.7.0" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">v3.7.0</span><span class="cf-date">June 16, 2026</span></header>
<p class="cf-entry-lead">Impeccable now carries project design context into hooks, the detector, and docs. The agent can compare edits against your actual typography, color, and radius system instead of only using global anti-pattern rules.</p>
<ul class="cf-items">
<li><strong>Design-aware hook detections.</strong> When a project has <code>DESIGN.md</code>, hooks and <code>impeccable detect</code> load the local design system and flag font, color, and radius drift against the documented palette, typography stacks, rounded scale, and sidecar tonal ramps.</li>
@@ -95,6 +103,14 @@ import '../styles/changelog-faq-kinpaku.css';
</ul>
</article>
<article id="cli-v3.0.3" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.3</span><span class="cf-date">June 17, 2026</span></header>
<ul class="cf-items">
<li><strong>Existing installs now pick up script-only skill fixes.</strong> <code>impeccable update</code> compares the complete bundled skill tree instead of just <code>SKILL.md</code>, so fixes in bundled scripts are detected. Running <code>impeccable install</code> on an already-installed project also refreshes stale skill files instead of stopping at "already installed."</li>
<li><strong>Codex installs report the skill version.</strong> The <code>.agents</code> and <code>.codex</code> builds now include the same <code>version</code> frontmatter as the other harnesses, so check/update output can show the installed skill version consistently.</li>
</ul>
</article>
<article id="cli-v3.0.2" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.2</span><span class="cf-date">June 16, 2026</span></header>
<ul class="cf-items">
@@ -157,6 +173,15 @@ import '../styles/changelog-faq-kinpaku.css';
</ul>
</article>
<article id="ext-v1.2.1" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">Extension v1.2.1</span><span class="cf-date">June 18, 2026</span></header>
<ul class="cf-items">
<li><strong>The toolbar badge count matches the popup and panel.</strong> The browser-action badge counted flagged elements while the popup and DevTools panel counted anti-pattern findings, so the same scan showed two numbers (for example 21 vs 34). One element can carry several findings; the badge now counts findings too, so every surface agrees. Reported in <a href="https://github.com/pbakaus/impeccable/issues/262" target="_blank" rel="noopener">#262</a>.</li>
<li><strong>Local file scans report why they fail.</strong> Scanning a <code>file://</code> page with file-URL access off used to leave the popup stuck on "Scanning…" forever. The popup now shows the real reason, with a permission hint for local files, and clears it on the next scan. Reported in <a href="https://github.com/pbakaus/impeccable/issues/258" target="_blank" rel="noopener">#258</a>.</li>
<li><strong>Popup matches the Kinpaku design system.</strong> The toolbar popup picks up the canonical carved-tile mark and light/dark theming that follows your system preference. Reported in <a href="https://github.com/pbakaus/impeccable/issues/260" target="_blank" rel="noopener">#260</a>.</li>
</ul>
</article>
<article id="ext-v1.2.0" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">Extension v1.2.0</span><span class="cf-date">June 14, 2026</span></header>
<ul class="cf-items">
+12 -24
View File
@@ -39,56 +39,50 @@ const startSteps = [
{
label: 'Install',
command: 'npx impeccable install',
line: 'Run this from the project root, then reload your AI coding tool.',
line: 'Run from the project root, then reload your agent.',
},
{
label: 'Set context',
command: '/impeccable init',
line: 'Answer the short setup interview. Say yes when it offers DESIGN.md.',
line: 'Create PRODUCT.md and DESIGN.md.',
},
{
label: 'Try it',
command: '/impeccable polish the pricing page',
line: 'Pick any page or component that already exists.',
line: 'Point it at a real page.',
},
];
const choices = [
{
need: 'Set up a project',
need: 'Start a project',
command: '/impeccable init',
href: '/docs/init',
line: 'Create PRODUCT.md and DESIGN.md.',
},
{
need: 'Improve an existing page',
need: 'Polish a page',
command: '/impeccable polish',
href: '/docs/polish',
line: 'Fix spacing, type, states, copy, and consistency.',
},
{
need: 'Find design issues',
command: '/impeccable critique',
href: '/docs/critique',
line: 'Get the highest-priority design problems.',
},
{
need: 'Check implementation quality',
need: 'Check implementation',
command: '/impeccable audit',
href: '/docs/audit',
line: 'Check accessibility, responsiveness, performance, and detector findings.',
},
{
need: 'Point at UI in the browser',
need: 'Iterate in the browser',
command: '/impeccable live',
href: '/docs/live',
line: 'Pick an element, generate variants, accept one into source.',
},
{
need: 'Build a new feature',
command: '/impeccable craft',
href: '/docs/craft',
line: 'Plan, build, and visually check the feature.',
},
];
@@ -97,25 +91,21 @@ const manualTopics = [
topic: 'Design Context',
href: '/docs/context',
label: 'PRODUCT.md + DESIGN.md',
line: 'What gets loaded, when it wins, and how the sidecar fits.',
},
{
topic: 'Detector CLI',
href: '/docs/detector',
label: 'npx impeccable detect',
line: 'Run deterministic checks on files, folders, stdin, and URLs.',
},
{
topic: 'Design hooks',
href: '/docs/hooks',
label: '/impeccable hooks',
line: 'Automatic detector feedback inside Claude Code, Codex, and Cursor.',
},
{
topic: 'Config and ignores',
href: '/docs/config',
label: '.impeccable/config.json',
line: 'Shared exceptions, private overrides, and design-system toggles.',
},
].filter(topic => referenceEntries.some(entry => `/docs/${entry.id}` === topic.href));
@@ -143,7 +133,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
<section class="docs-start-rail" aria-labelledby="docs-start-h">
<header class="docs-start-rail-head">
<h1 class="docs-start-rail-title" id="docs-start-h">Start with <span>/impeccable</span></h1>
<p class="docs-start-rail-lede">Install, run setup, then try one command on an existing page.</p>
<p class="docs-start-rail-lede">Install, add context, then run one command.</p>
</header>
<div class="docs-start-rail-sequence">
@@ -173,7 +163,6 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
<a class="docs-chooser-list-row" href={choice.href}>
<span class="docs-chooser-list-need">{choice.need}</span>
<span class="docs-chooser-list-command">{choice.command}</span>
<span class="docs-chooser-list-line">{choice.line}</span>
</a>
))}
</div>
@@ -182,7 +171,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
<section class="docs-manual-section" aria-labelledby="docs-manual-h">
<header class="docs-manual-head">
<h2 class="docs-manual-title" id="docs-manual-h">Understand the system</h2>
<p class="docs-manual-sub">Commands are only one layer. These docs explain the files, checks, hooks, and config that make them behave consistently.</p>
<p class="docs-manual-sub">Context files, detector checks, hooks, and config.</p>
</header>
<div class="docs-chooser-list docs-manual-list">
@@ -190,7 +179,6 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
<a class="docs-chooser-list-row" href={topic.href}>
<span class="docs-chooser-list-need">{topic.topic}</span>
<span class="docs-chooser-list-command">{topic.label}</span>
<span class="docs-chooser-list-line">{topic.line}</span>
</a>
))}
</div>
@@ -199,11 +187,11 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
<!-- Live mode: visual iteration -->
<section class="docs-live" aria-labelledby="docs-live-h">
<div class="docs-live-copy">
<h2 class="docs-live-title" id="docs-live-h">Iterate where you can see it</h2>
<p class="docs-live-lede">Use Live Mode when an issue is easier to point at than describe. Pick an element, generate variants, accept one into source.</p>
<h2 class="docs-live-title" id="docs-live-h">Iterate in the browser</h2>
<p class="docs-live-lede">Pick UI, generate variants, accept one into source.</p>
<p class="docs-live-cta">
<a class="ks-button ks-button-primary" href="/docs/live">
Read the live docs
Live Mode docs
<span class="ks-button-arrow" aria-hidden="true">
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M0 4h14M10 0l4 4-4 4"/></svg>
</span>
+14 -15
View File
@@ -17,8 +17,7 @@ import '../../styles/live-mode-kinpaku.css';
<div class="live-mode-page">
<header class="live-mode-page-header">
<p class="live-mode-page-eyebrow">New in v3.0 <span class="live-mode-page-eyebrow-badge">Beta</span></p>
<h1 class="live-mode-page-title">Live Mode</h1>
<h1 class="live-mode-page-title">Live Mode <span class="live-mode-page-title-badge">Beta</span></h1>
<p class="live-mode-page-lede">Pick any element in the browser. Drop a comment or a stroke. Three production-quality variants swap in via your framework's HMR. Accept the one you want and it writes back to source.</p>
<div class="live-mode-start" aria-label="Start live mode command">
<span class="live-mode-start-prompt">$</span>
@@ -181,11 +180,11 @@ import '../../styles/live-mode-kinpaku.css';
<span class="live-mode-stage-num">02 &middot; Generate</span>
<h3 class="live-mode-stage-name">Three genuinely different takes</h3>
<p class="live-mode-stage-desc">Variants anchor to different archetypes, not three riffs on color. Each one explores a different primary axis: hierarchy, typography, density, layout, or palette strategy.</p>
<div class="live-mode-stage-viz">
<div class="docs-viz-variants" style="width:100%;gap:4px">
<div class="docs-viz-variant docs-viz-variant--v1" style="min-height:44px;padding:6px"><span class="docs-viz-variant-kicker" style="font-size:8px">No.04</span></div>
<div class="docs-viz-variant docs-viz-variant--v2 is-active" style="min-height:44px;padding:6px"><span class="docs-viz-variant-kicker" style="font-size:8px">Dispatch</span></div>
<div class="docs-viz-variant docs-viz-variant--v3" style="min-height:44px;padding:6px"><span class="docs-viz-variant-kicker" style="font-size:8px">Field</span></div>
<div class="live-mode-stage-viz live-mode-stage-viz--rail">
<div class="viz-rail">
<div class="viz-rail-cell"><span>No.04</span><span class="viz-rail-mark"></span></div>
<div class="viz-rail-cell is-active"><span>Dispatch</span><span class="viz-rail-mark"></span></div>
<div class="viz-rail-cell"><span>Field</span><span class="viz-rail-mark"></span></div>
</div>
</div>
</article>
@@ -226,14 +225,14 @@ import '../../styles/live-mode-kinpaku.css';
<section class="live-mode-frameworks" aria-label="Supported frameworks">
<span class="live-mode-frameworks-label">Supported dev servers</span>
<ul class="live-mode-frameworks-list">
<li>Vite</li>
<li>Next.js (incl. monorepos)</li>
<li>SvelteKit</li>
<li>Astro</li>
<li>Nuxt</li>
<li>Bun</li>
<li>Plain static HTML</li>
<ul class="live-mode-frameworks-list framework-list--matrix">
<li><small>bundler</small><strong>Vite</strong></li>
<li><small>apps</small><strong>Next.js</strong></li>
<li><small>apps</small><strong>SvelteKit</strong></li>
<li><small>pages</small><strong>Astro</strong></li>
<li><small>apps</small><strong>Nuxt</strong></li>
<li><small>runtime</small><strong>Bun</strong></li>
<li><small>static</small><strong>Plain HTML</strong></li>
</ul>
</section>
</div>
+1 -2
View File
@@ -47,7 +47,6 @@ import '../../styles/slop-kinpaku.css';
<div class="anti-patterns-content slop-content">
<header class="anti-patterns-header slop-header">
<p class="sub-page-eyebrow">The visible tells of AI design</p>
<h1 class="sub-page-title">Slop</h1>
<p class="sub-page-lede">46 patterns that mark an interface as AI-generated, and the detection overlay that catches them in place. Watch it flag them live, try it on 11 synthetic specimens, or browse the full catalog. 41 rules run deterministically (<code>npx impeccable detect</code> or the browser extension), 4 of them opt-in provider tells behind <code>--gpt</code> / <code>--gemini</code>; 5 need <a href="/docs/critique">/impeccable critique</a>'s LLM review pass.</p>
</header>
@@ -56,7 +55,7 @@ import '../../styles/slop-kinpaku.css';
<h2 class="slop-section-heading">See it</h2>
<div class="slop-then-now-intro">
<p class="slop-then-now-lede">Every wave of AI-generated UIs converges on a recognizable aesthetic. The detector catches both. The patterns just change.</p>
<div class="slop-era-toggle" role="tablist" aria-label="Select slop era">
<div class="slop-era-toggle slop-era-toggle--ink" role="tablist" aria-label="Select slop era">
<button class="slop-era-tab is-active" role="tab" aria-selected="true" data-era="2022">2022</button>
<button class="slop-era-tab" role="tab" aria-selected="false" data-era="2026">2026</button>
</div>
+65 -76
View File
@@ -37,6 +37,8 @@
--docs-brand-accent: var(--ks-kinpaku);
--docs-panel-bg: var(--ks-lacquer-raised);
--docs-panel-hover-bg: oklch(11% 0.012 82);
--docs-row-bg: var(--ks-lacquer-raised);
--docs-row-hover-bg: oklch(13% 0.012 82);
background:
linear-gradient(180deg, oklch(7% 0.006 95), oklch(4% 0.004 95));
@@ -46,11 +48,6 @@
}
html.light .docs-kinpaku {
--ks-lacquer: oklch(100% 0 0);
--ks-lacquer-deep: oklch(100% 0 0);
--ks-lacquer-raised: oklch(99% 0 0);
--ks-graphite: oklch(94% 0 0);
--ks-graphite-2: oklch(90% 0 0);
--ks-champagne: oklch(16% 0 0);
--ks-text: oklch(24% 0 0);
--ks-text-muted: oklch(42% 0 0);
@@ -61,9 +58,11 @@ html.light .docs-kinpaku {
--docs-accent-line: oklch(70% 0.12 188 / 0.36);
--docs-accent-wash: oklch(70% 0.12 188 / 0.08);
--docs-brand-accent: var(--ks-kinpaku);
--docs-panel-bg: oklch(100% 0 0 / 0.76);
--docs-panel-hover-bg: oklch(70% 0.12 188 / 0.07);
background: oklch(100% 0 0);
--docs-panel-bg: oklch(100% 0 0);
--docs-panel-hover-bg: color-mix(in oklch, oklch(100% 0 0), var(--docs-accent) 4%);
--docs-row-bg: oklch(100% 0 0);
--docs-row-hover-bg: color-mix(in oklch, oklch(100% 0 0), var(--docs-accent) 3%);
background: linear-gradient(180deg, var(--ks-lacquer), var(--ks-lacquer-deep));
}
.docs-kinpaku a {
@@ -275,44 +274,48 @@ html.light .docs-kinpaku {
.docs-kinpaku .docs-start-rail {
display: grid;
gap: clamp(26px, 4vw, 44px);
margin-bottom: clamp(48px, 7vw, 88px);
gap: 28px;
margin-bottom: clamp(44px, 6vw, 72px);
}
@media (max-width: 520px) {
.docs-kinpaku .docs-index {
padding-top: 22px;
}
.docs-kinpaku .docs-start-rail-title {
font-size: 2.15rem;
}
}
.docs-kinpaku .docs-start-rail-head {
display: grid;
gap: 18px;
max-width: 1040px;
gap: 14px;
max-width: 780px;
}
.docs-kinpaku .docs-start-rail-title {
font-family: var(--ks-font-display);
font-family: var(--ks-font);
font-style: normal;
font-weight: var(--ks-type-display-weight);
font-size: clamp(3.2rem, calc(3.1rem + 2vw), 5.6rem);
line-height: var(--ks-type-display-line);
letter-spacing: var(--ks-type-display-track);
font-weight: 650;
font-size: 2.6rem;
line-height: 1.12;
letter-spacing: 0;
color: var(--ks-champagne);
margin: 0;
text-wrap: balance;
}
.docs-kinpaku .docs-start-rail-title span {
color: var(--docs-brand-accent);
font-family: var(--ks-font-wordmark);
color: var(--docs-accent);
font-family: var(--ks-mono);
font-weight: 500;
font-size: 0.96em;
font-size: 0.82em;
}
.docs-kinpaku .docs-start-rail-lede {
font-size: 1.04rem;
line-height: 1.62;
font-size: 1rem;
line-height: 1.6;
color: var(--ks-text);
margin: 0;
}
@@ -326,7 +329,7 @@ html.light .docs-kinpaku {
.docs-kinpaku .docs-flow-section,
.docs-kinpaku .docs-chains-section,
.docs-kinpaku .docs-reference-section {
margin-bottom: clamp(64px, 8vw, 110px);
margin-bottom: clamp(48px, 6vw, 76px);
}
.docs-kinpaku .docs-reference-section:last-child {
@@ -339,14 +342,14 @@ html.light .docs-kinpaku {
.docs-kinpaku .docs-flow-title,
.docs-kinpaku .docs-chains-title,
.docs-kinpaku .docs-reference-title {
font-family: var(--ks-font-display);
font-family: var(--ks-font);
font-style: normal;
font-weight: var(--ks-type-headline-weight);
font-size: var(--ks-type-headline-size);
line-height: var(--ks-type-headline-line);
font-weight: 650;
font-size: 1.55rem;
line-height: 1.25;
letter-spacing: 0;
color: var(--ks-champagne);
margin: 0 0 14px;
margin: 0 0 12px;
text-wrap: balance;
}
@@ -375,6 +378,7 @@ html.light .docs-kinpaku {
padding: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
border-top: 0;
border-bottom: 0;
}
@@ -390,12 +394,10 @@ html.light .docs-kinpaku {
grid-template-rows: auto auto minmax(3.2em, auto) auto;
gap: 10px;
min-width: 0;
padding: 22px clamp(18px, 2.8vw, 30px) 24px;
border-right: 1px solid var(--ks-rule);
}
.docs-kinpaku .docs-start-rail-step:last-child {
border-right: 0;
padding: 18px 20px 20px;
background: var(--docs-row-bg);
border: 1px solid transparent;
border-radius: 8px;
}
.docs-kinpaku .docs-start-rail-num {
@@ -429,9 +431,9 @@ html.light .docs-kinpaku {
.docs-kinpaku .docs-start-rail-steps--number-gutter .docs-start-rail-step {
grid-template-columns: minmax(34px, auto) minmax(0, 1fr);
grid-template-rows: auto auto auto;
column-gap: clamp(16px, 2vw, 24px);
column-gap: 16px;
row-gap: 9px;
padding: 22px clamp(18px, 2.8vw, 30px) 24px 0;
padding: 18px 20px 20px;
}
.docs-kinpaku .docs-start-rail-steps--number-gutter .docs-start-rail-num {
@@ -446,14 +448,6 @@ html.light .docs-kinpaku {
grid-column: 2;
}
.docs-kinpaku .docs-start-rail-steps--inner-inset .docs-start-rail-step:first-child {
padding-left: 0;
}
.docs-kinpaku .docs-start-rail-steps--inner-inset .docs-start-rail-step:not(:first-child) {
padding-left: clamp(16px, 2.2vw, 24px);
}
.docs-kinpaku .docs-start-rail-line {
font-size: 0.9rem;
line-height: 1.5;
@@ -481,15 +475,7 @@ html.light .docs-kinpaku {
@media (max-width: 940px) {
.docs-kinpaku .docs-start-rail-step,
.docs-kinpaku .docs-start-rail-step:last-child {
border-right: 0;
border-left: 0;
border-top: 0;
border-bottom: 1px solid var(--ks-rule);
padding-inline: 0;
}
.docs-kinpaku .docs-start-rail-step:last-child {
border-bottom: 0;
border: 1px solid transparent;
}
.docs-kinpaku .docs-start-rail-command {
@@ -500,64 +486,67 @@ html.light .docs-kinpaku {
/* b. Intent chooser ---------------------------------------------------- */
.docs-kinpaku .docs-chooser-head {
margin-bottom: 26px;
.docs-kinpaku .docs-chooser-head,
.docs-kinpaku .docs-manual-head {
margin-bottom: 16px;
}
.docs-kinpaku .docs-chooser-list {
display: grid;
grid-template-columns: 1fr;
border-top: 1px solid var(--ks-rule);
gap: 12px;
border-top: 0;
}
.docs-kinpaku .docs-chooser-list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 22px;
align-items: baseline;
padding: 18px 0 20px;
background: transparent;
border: 0;
border-bottom: 1px solid var(--ks-rule);
border-radius: 0;
gap: 7px 18px;
align-items: center;
padding: 18px 20px;
background: var(--docs-row-bg);
border: 1px solid transparent;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 160ms var(--ks-ease),
background 160ms var(--ks-ease),
color 160ms var(--ks-ease);
}
.docs-kinpaku .docs-chooser-list-row:hover {
border-bottom-color: var(--docs-accent-line);
background: var(--docs-row-hover-bg);
border-color: var(--docs-accent-line);
}
.docs-kinpaku .docs-chooser-list-need {
font-size: 1rem;
font-size: 0.98rem;
font-weight: 600;
line-height: 1.35;
color: var(--ks-champagne);
}
.docs-kinpaku .docs-chooser-list-command {
display: inline-flex;
justify-self: end;
font-family: var(--ks-mono);
font-size: 0.78rem;
color: var(--docs-accent);
line-height: 1.4;
font-size: 0.74rem;
color: var(--docs-accent-hover);
line-height: 1.3;
padding: 5px 8px;
background: var(--docs-accent-wash);
border: 1px solid var(--docs-accent-line);
border-radius: 5px;
white-space: nowrap;
}
.docs-kinpaku .docs-chooser-list-line {
grid-column: 1 / -1;
font-size: 0.9rem;
line-height: 1.5;
color: var(--ks-text-muted);
max-width: 68ch;
}
@media (max-width: 520px) {
.docs-kinpaku .docs-chooser-list-row {
grid-template-columns: 1fr;
}
.docs-kinpaku .docs-chooser-list-command {
justify-self: start;
white-space: normal;
}
}
+3 -3
View File
@@ -1194,7 +1194,7 @@
display: inline-flex;
align-items: center;
height: 28px;
width: 88px;
width: 104px;
margin: 0 4px 0 6px;
border-radius: 5px;
background: var(--ks-graphite);
@@ -1202,12 +1202,12 @@
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
transition: width 0.18s ease, border-color 0.15s ease;
transition: border-color 0.15s ease;
}
.home-kinpaku .live-demo-gbar-chat.is-expanded,
.live-mode-kinpaku .live-demo-gbar-chat.is-expanded {
width: min(220px, 42vw);
width: min(220px, 34vw);
cursor: text;
}
+22 -15
View File
@@ -776,6 +776,17 @@ html.light .slop-kinpaku .slop-layer {
border-color: var(--ks-rule);
}
html.light .slop-kinpaku .gallery-card:hover,
html.light .slop-kinpaku .rule-card:hover {
background: var(--ks-lacquer-raised);
border-color: oklch(49% 0.08 188 / 0.34);
}
html.light .slop-kinpaku .gallery-card:hover .gallery-card-title,
html.light .slop-kinpaku .rule-card:hover .rule-card-name {
color: var(--ks-champagne);
}
html.light .live-mode-kinpaku .live-mode-hero-panel,
html.light .live-mode-kinpaku .live-mode-step {
background: var(--ks-lacquer-raised);
@@ -1160,14 +1171,6 @@ html.light .changelog-faq-kinpaku {
background: linear-gradient(180deg, var(--ks-lacquer), var(--ks-lacquer-deep));
}
html.light body.docs-kinpaku {
background: oklch(100% 0 0);
}
html.light body.docs-kinpaku .site-header {
background: oklch(100% 0 0 / 0.96);
}
html.light .skills-layout-page .skills-sidebar,
html.light .skills-layout-page .skills-content {
background: transparent;
@@ -1216,15 +1219,10 @@ html.light .docs-kinpaku .prose .docs-viz-flow-step + .docs-viz-flow-step::befor
color: var(--docs-accent);
}
html.light .live-mode-kinpaku .live-mode-stage-viz .docs-viz-variant.is-active .docs-viz-variant-kicker,
html.light .live-mode-kinpaku .live-mode-stage-viz .docs-viz-accept-pill {
html.light .live-mode-kinpaku .live-mode-stage-viz .docs-viz-variant.is-active .docs-viz-variant-kicker {
color: var(--ks-kinpaku-ink);
}
html.light .live-mode-kinpaku .live-mode-stage-viz .docs-viz-accept-pill {
border-color: var(--ks-kinpaku-deep);
}
/* Docs command demos — light surfaces (divider fix lives in docs-kinpaku.css) */
html.light .docs-kinpaku .docs-command-demo .split-container {
background:
@@ -1271,7 +1269,7 @@ html.light .site-header-github-star {
}
html.light .slop-kinpaku .skills-sidebar {
background: var(--ks-lacquer-raised);
background: transparent;
border-color: var(--ks-rule);
}
@@ -1607,6 +1605,15 @@ html.light .slop-kinpaku .slop-catalog-lede a:hover {
border-bottom-color: var(--ks-link-on-paper-hover);
}
html.light .slop-kinpaku :is(
.sub-page-lede,
.anti-patterns-legend-body,
.visual-mode-method-name,
.visual-mode-method-desc
) code {
background: oklch(25% 0.02 95 / 0.08);
}
html.light .cf-items a,
html.light .cf-faq-answer a {
color: var(--ks-link-on-paper);
+100 -22
View File
@@ -88,6 +88,10 @@
}
.live-mode-kinpaku .live-mode-page-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
font-family: var(--ks-font-display);
font-style: normal;
font-weight: var(--ks-type-display-weight);
@@ -99,6 +103,23 @@
text-wrap: balance;
}
.live-mode-kinpaku .live-mode-page-title-badge {
display: inline-flex;
align-items: center;
align-self: center;
padding: 3px 9px;
border: 1px solid oklch(70% 0.12 188 / 0.4);
border-radius: 2px;
background: oklch(70% 0.12 188 / 0.10);
color: var(--ks-patina);
font-family: var(--ks-mono);
font-size: 0.64rem;
font-weight: 500;
letter-spacing: 0.18em;
line-height: 1.2;
text-transform: uppercase;
}
.live-mode-kinpaku .live-mode-page-lede {
font-size: 1.1rem;
line-height: 1.65;
@@ -113,10 +134,11 @@
without blocking the demo. */
.live-mode-kinpaku .live-mode-page-alpha-note {
margin: 14px 0 0;
padding: 0;
padding: 0 0 0 14px;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: inset 2px 0 0 var(--ks-kinpaku);
font-size: 0.82rem;
line-height: 1.5;
color: var(--ks-text-muted);
@@ -305,8 +327,8 @@
.live-mode-kinpaku .live-demo-card--v1 {
background: var(--ks-lacquer-raised);
border: 0;
border-top: 3px solid var(--ks-kinpaku);
border-radius: 0;
box-shadow: inset 0 3px 0 var(--ks-kinpaku);
}
.live-mode-kinpaku .live-demo-card--v1 .live-demo-card-kicker {
@@ -368,8 +390,8 @@
.live-mode-kinpaku .live-demo-card--v3 {
background: oklch(11% 0.012 82);
border: 1px solid var(--ks-rule);
color: var(--ks-text);
border: 1px solid oklch(78% 0 0 / 0.16);
color: oklch(88% 0 0);
}
.live-mode-kinpaku .live-demo-card--v3 .live-demo-card-kicker {
@@ -380,7 +402,7 @@
font-family: var(--ks-font);
font-style: normal;
font-weight: 600;
color: var(--ks-champagne);
color: oklch(91% 0 0);
}
.live-mode-kinpaku .live-demo-card--v3 h3 em {
@@ -540,34 +562,58 @@
box-shadow: none;
}
/* Three-up variants strip (GENERATE card) lacquer tiles, active gets
the kinpaku-tinted bg. */
.live-mode-kinpaku .live-mode-stage-viz .docs-viz-variant {
background: var(--ks-lacquer-raised);
/* Generate card measured rail with one clearly active take. The active
state uses solid kinpaku instead of a patina wash so it stays crisp in
light mode. */
.live-mode-kinpaku .live-mode-stage-viz--rail {
background: var(--ks-lacquer-deep);
}
.live-mode-kinpaku .viz-rail {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
width: 100%;
min-height: 62px;
border: 1px solid var(--ks-rule);
border-radius: 3px;
overflow: hidden;
background: var(--ks-lacquer-raised);
}
.live-mode-kinpaku .viz-rail-cell {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 8px;
color: var(--ks-text-muted);
box-shadow: none;
font-family: var(--ks-mono);
font-size: 8px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.live-mode-kinpaku .live-mode-stage-viz .docs-viz-variant.is-active {
background: oklch(78% 0.12 82 / 0.16);
border-color: var(--ks-kinpaku);
box-shadow: none;
.live-mode-kinpaku .viz-rail-cell + .viz-rail-cell {
box-shadow: inset 1px 0 0 var(--ks-rule);
}
.live-mode-kinpaku .live-mode-stage-viz .docs-viz-variant-kicker {
color: var(--ks-text-muted);
.live-mode-kinpaku .viz-rail-cell.is-active {
background: var(--ks-kinpaku);
color: oklch(8% 0.006 95);
}
.live-mode-kinpaku .live-mode-stage-viz .docs-viz-variant.is-active .docs-viz-variant-kicker {
color: var(--ks-kinpaku);
.live-mode-kinpaku .viz-rail-mark {
width: 18px;
height: 2px;
background: currentColor;
}
/* Accept pill (ACCEPT card) — kinpaku gold ink on a subtle kinpaku wash. */
/* Accept pill (ACCEPT card) — patina on lacquer, not a pale gold wash. */
.live-mode-kinpaku .live-mode-stage-viz .docs-viz-accept-pill {
background: oklch(78% 0.12 82 / 0.16);
border: 1px solid var(--ks-kinpaku);
color: var(--ks-kinpaku);
background: oklch(8% 0.006 95);
border: 1px solid oklch(70% 0.12 188 / 0.58);
color: oklch(82% 0.07 188);
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.05);
}
@@ -701,6 +747,38 @@
border-radius: 2px;
}
.live-mode-kinpaku .framework-list--matrix {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
background: var(--ks-rule);
border: 1px solid var(--ks-rule);
border-radius: 3px;
overflow: hidden;
}
.live-mode-kinpaku .framework-list--matrix li {
display: grid;
gap: 4px;
min-height: 54px;
padding: 9px 10px;
background: var(--ks-lacquer-raised);
border: 0;
border-radius: 0;
}
.live-mode-kinpaku .framework-list--matrix small {
color: var(--ks-patina-deep);
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.live-mode-kinpaku .framework-list--matrix strong {
color: var(--ks-champagne);
font-weight: 500;
}
/* ============================================================================
7. FOOTER on dark surface
+9 -3
View File
@@ -292,13 +292,17 @@
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
flex: 0 0 auto;
padding: 4px 6px 4px 2px;
}
.live-demo-gbar-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex: 0 0 auto;
min-width: 30px;
flex-shrink: 0;
padding: 6px 8px;
background: transparent;
@@ -315,7 +319,9 @@
transition: background 0.15s ease, color 0.15s ease;
}
.live-demo-gbar-btn svg {
flex-shrink: 0;
width: 14px;
height: 14px;
flex: 0 0 14px;
}
.live-demo-gbar-btn-label {
display: inline-block;
@@ -358,7 +364,7 @@
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-shrink: 0;
flex: 0 0 24px;
align-self: center;
width: 24px;
height: 24px;
+44 -29
View File
@@ -275,12 +275,12 @@
.slop-kinpaku .sub-page-lede code {
font-family: var(--ks-mono);
font-size: 0.86em;
color: var(--ks-champagne);
background: var(--ks-graphite-2);
border: 1px solid var(--ks-rule);
padding: 1px 7px;
border-radius: 2px;
font-size: 0.84em;
color: var(--ks-code-fg);
background: var(--ks-code-bg);
border: 0;
padding: 0.14em 0.36em;
border-radius: var(--ks-code-radius);
}
.slop-kinpaku .sub-page-lede a {
@@ -386,6 +386,26 @@
color: var(--ks-lacquer-deep);
}
.slop-kinpaku .slop-era-toggle--ink {
background: color-mix(in oklch, var(--ks-graphite), transparent 28%);
border-color: color-mix(in oklch, var(--ks-rule), var(--ks-text) 10%);
}
.slop-kinpaku .slop-era-toggle--ink .slop-era-tab {
padding: 8px 17px;
color: var(--ks-text-muted);
}
.slop-kinpaku .slop-era-toggle--ink .slop-era-tab:hover {
color: var(--ks-text);
}
.slop-kinpaku .slop-era-toggle--ink .slop-era-tab.is-active {
background: color-mix(in oklch, var(--ks-champagne), var(--ks-lacquer-raised) 16%);
color: var(--ks-lacquer);
box-shadow: inset 0 0 0 1px oklch(70% 0.12 188 / 0.18);
}
/* Browser frame mock around the iframe. Lacquer-deep shell with kinpaku
chrome bar, matching the docs-kinpaku live-mode mock vocabulary. */
.slop-kinpaku .visual-mode-preview {
@@ -393,7 +413,7 @@
border: 1px solid var(--ks-rule);
border-radius: 4px;
overflow: hidden;
box-shadow: 0 24px 60px oklch(2% 0.004 95 / 0.55);
box-shadow: none;
}
.slop-kinpaku .visual-mode-preview-header {
@@ -497,11 +517,6 @@
object-position: top;
display: block;
filter: saturate(1.05);
transition: transform 320ms var(--ks-ease);
}
.slop-kinpaku .gallery-card:hover .gallery-card-thumb img {
transform: scale(1.04);
}
.slop-kinpaku .gallery-card-body {
@@ -623,12 +638,12 @@
.slop-kinpaku .anti-patterns-legend-body code {
font-family: var(--ks-mono);
font-size: 0.86em;
color: var(--ks-champagne);
background: var(--ks-graphite-2);
border: 1px solid var(--ks-rule);
padding: 1px 6px;
border-radius: 2px;
font-size: 0.84em;
color: var(--ks-code-fg);
background: var(--ks-code-bg);
border: 0;
padding: 0.14em 0.36em;
border-radius: var(--ks-code-radius);
}
.slop-kinpaku .anti-patterns-legend-layers {
@@ -922,11 +937,11 @@
.slop-kinpaku .visual-mode-method-name code {
font-family: var(--ks-mono);
font-size: 0.92em;
color: var(--ks-champagne);
background: var(--ks-graphite-2);
border: 1px solid var(--ks-rule);
padding: 1px 8px;
border-radius: 2px;
color: var(--ks-code-fg);
background: var(--ks-code-bg);
border: 0;
padding: 0.14em 0.36em;
border-radius: var(--ks-code-radius);
}
.slop-kinpaku .visual-mode-method-desc {
@@ -938,12 +953,12 @@
.slop-kinpaku .visual-mode-method-desc code {
font-family: var(--ks-mono);
font-size: 0.86em;
color: var(--ks-champagne);
background: var(--ks-graphite-2);
border: 1px solid var(--ks-rule);
padding: 1px 6px;
border-radius: 2px;
font-size: 0.84em;
color: var(--ks-code-fg);
background: var(--ks-code-bg);
border: 0;
padding: 0.14em 0.36em;
border-radius: var(--ks-code-radius);
}
.slop-kinpaku .visual-mode-method-desc a {
+90 -15
View File
@@ -6489,10 +6489,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -8186,7 +8189,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8200,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8280,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8361,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8389,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8627,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8723,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9037,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9337,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9345,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9395,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9404,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9426,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9678,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9695,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9708,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9735,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9782,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
+62
View File
@@ -324,3 +324,65 @@ Please audit {{target}} for technical quality. Ask {{model}} for help.`;
expect(fs.existsSync(path.join(DIST_DIR, 'kiro/.kiro/skills'))).toBe(true);
});
});
// Resolve a relative import specifier against the importer's bundle-relative
// path, mirroring Node ESM resolution against the set of bundled script names.
// Returns the matching bundled name, or null if nothing resolves.
function resolveBundledImport(importerName, specifier, names) {
const dirParts = importerName.split('/').slice(0, -1);
const parts = dirParts.concat(specifier.split('/'));
const resolved = [];
for (const part of parts) {
if (part === '' || part === '.') continue;
if (part === '..') { resolved.pop(); continue; }
resolved.push(part);
}
const base = resolved.join('/');
// ESM needs an explicit extension, but be tolerant of extensionless and
// index specifiers so the check tracks real module-resolution behavior.
const candidates = [base, `${base}.mjs`, `${base}.js`, `${base}/index.mjs`, `${base}/index.js`];
return candidates.find((c) => names.has(c)) || null;
}
// Regression guard for issue #254: the bundled detector imported
// `../../lib/impeccable-config.mjs`, a file that lives outside `cli/engine` and
// was never copied into the bundle, so `/impeccable critique` crashed with
// "Cannot find module .../lib/impeccable-config.mjs". This walks every bundled
// script and asserts each relative import resolves to another bundled file, so
// any future out-of-bundle dependency fails the build instead of the user.
describe('bundled skill scripts are self-contained', () => {
const ROOT_DIR = process.cwd();
const { skills } = utils.readSourceFiles(ROOT_DIR);
const scripts = skills[0]?.scripts ?? [];
const jsScripts = scripts.filter((s) => /\.(mjs|js)$/.test(s.name));
const names = new Set(scripts.map((s) => s.name));
// Static `import ... from '...'` and re-export `export ... from '...'` only;
// dynamic `import()` of computed paths (e.g. detect.mjs) is out of scope.
const importRe = /(?:^|[\s;])(?:import|export)\b[^'"`]*?\bfrom\s*['"]([^'"]+)['"]/g;
// Drop comments first so an example like `// import ... from '...'` in a
// doc comment (detector/node/file-system.mjs has one) isn't read as a real import.
const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
test('the detector bundle includes its config dependency', () => {
expect(names.has('lib/impeccable-config.mjs')).toBe(true);
});
test('every relative import resolves to a bundled file', () => {
const broken = [];
for (const script of jsScripts) {
const source = stripComments(script.content);
importRe.lastIndex = 0;
let match;
while ((match = importRe.exec(source)) !== null) {
const specifier = match[1];
if (!specifier.startsWith('.')) continue; // bare/node specifiers
if (!resolveBundledImport(script.name, specifier, names)) {
broken.push(`${script.name} -> ${specifier}`);
}
}
}
expect(broken).toEqual([]);
});
});
+29 -1
View File
@@ -10,6 +10,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import {
detectHtml,
detectText,
normalizeDesignSystem,
} from '../cli/engine/detect-antipatterns.mjs';
@@ -205,7 +206,7 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
assert.equal(leftFindings.length, 11, `expected 11 border-left findings, got ${leftFindings.length}`);
assert.equal(rightFindings.length, 1, `expected 1 border-right finding, got ${rightFindings.length}`);
// PASS column must contribute zero border findings of either flavor.
// There are 13 pass cases: 6 structural neutrals plus 4 labels (plain
// There are 14 pass cases: 7 structural neutrals plus 4 labels (plain
// inline form label, label with a neutral gray border, label in a form
// row, and a label with a thin 1px colored left border), plus 3 var()
// pass cases (neutral-resolving var, thin var, uniform all-sides var).
@@ -218,6 +219,33 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
);
});
it('modern-color-borders: regex fallback skips neutral 1px oklch dividers', () => {
const css = `
.flag-side-tab {
border-radius: 8px;
border-left: 2px solid oklch(65% 0.12 250);
}
.pass-context-divider {
border-radius: 8px;
border-right: 1px solid oklch(92% 0 0 / 0.12);
}
.pass-neutral-side {
border-radius: 8px;
border-left: 3px solid oklch(80% 0 0);
}
`;
const f = detectText(css, path.join(FIXTURES, 'modern-color-borders-regex.css'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
assert.equal(
sideTabs.length,
1,
`expected only the colored 2px side-tab to flag, got: ${sideTabs.map(r => r.snippet).join('; ')}`
);
assert.match(sideTabs[0].snippet, /border-left: 2px solid oklch/);
});
it('typography-should-flag: detects all three issues', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'overused-font'));
+43 -2
View File
@@ -136,11 +136,16 @@ describe('detectText — Tailwind side-tab', () => {
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
});
test('detects border-l-1 + rounded', () => {
const f = detectText('<div class="border-l-1 border-blue-500 rounded-md">', 'test.html');
test('detects border-l-2 + rounded', () => {
const f = detectText('<div class="border-l-2 border-blue-500 rounded-md">', 'test.html');
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
});
test('ignores border-l-1 + rounded', () => {
const f = detectText('<div class="border-l-1 border-blue-500 rounded-md">', 'test.html');
expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
});
test('ignores border-l-1 without rounded', () => {
const f = detectText('<div class="border-l-1 border-gray-300">', 'test.html');
expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
@@ -874,6 +879,7 @@ describe('CLI', () => {
const { stdout, code } = run('--help');
expect(code).toBe(0);
expect(stdout).toContain('Usage:');
expect(stdout).toContain('--quiet');
});
test('detect subcommand is not treated as a scan target', () => {
@@ -901,6 +907,32 @@ describe('CLI', () => {
expect(parsed.length).toBeGreaterThan(0);
});
test('--quiet suppresses text details and keeps the summary exit signal', () => {
const { stdout, stderr, code } = run('--quiet', path.join(FIXTURES, 'should-flag.html'));
expect(code).toBe(2);
expect(stdout).toBe('');
expect(stderr.trim()).toMatch(/^[1-9]\d* anti-patterns? found\.$/);
expect(stderr).not.toContain('side-tab');
expect(stderr).not.toContain('line ');
});
test('--quiet stays silent on clean files', () => {
const { stdout, stderr, code } = run('--quiet', path.join(FIXTURES, 'should-pass.html'));
expect(code).toBe(0);
expect(stdout).toBe('');
expect(stderr).toBe('');
});
test('--quiet does not affect JSON output', () => {
const { stdout, stderr, code } = run('--json', '--quiet', path.join(FIXTURES, 'should-flag.html'));
expect(code).toBe(2);
expect(stderr).toBe('');
const parsed = JSON.parse(stdout.trim());
expect(parsed).toBeArray();
expect(parsed.length).toBeGreaterThan(0);
expect(parsed.some(f => f.antipattern === 'side-tab')).toBe(true);
});
test('-json alias outputs valid JSON', () => {
const { stdout, stderr, code } = run('-json', path.join(FIXTURES, 'should-flag.html'));
expect(code).toBe(2);
@@ -1740,6 +1772,15 @@ describe('CLI -- dev server suggestion', () => {
expect(stderr).toContain('3000');
});
test('--quiet suppresses framework URL scan suggestions', () => {
const { stderr, code } = run('--quiet', path.join(FIXTURES, 'framework-next-tailwind'));
expect(code).toBe(2);
expect(stderr.trim()).toMatch(/^[1-9]\d* anti-patterns? found\.$/);
expect(stderr).not.toContain('Next.js');
expect(stderr).not.toContain('3000');
expect(stderr).not.toContain('Start the dev server');
});
test('suggests URL scan when Vite config found', () => {
const { stderr } = run(path.join(FIXTURES, 'framework-vite'));
expect(stderr).toContain('Vite');
+9
View File
@@ -264,6 +264,14 @@
border: 3px solid var(--brand);
border-radius: 4px;
}
/* 14: neutral 1px oklch right divider — context-bar hairline, not a side-tab. */
#pass-oklch-neutral-right-divider {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-right: 1px solid oklch(92% 0 0 / 0.12);
}
</style>
</head>
<body>
@@ -319,6 +327,7 @@
<div class="case" id="pass-var-neutral"><h3>var() neutral</h3><p>--line resolves to gray</p></div>
<div class="case" id="pass-var-thin"><h3>var() thin</h3><p>1px too thin to qualify</p></div>
<div class="case" id="pass-var-allsides"><h3>var() all sides</h3><p>uniform, not a side-tab</p></div>
<div class="case" id="pass-oklch-neutral-right-divider"><h3>oklch neutral right divider</h3><p>1px neutral context-bar hairline</p></div>
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
+7
View File
@@ -65,6 +65,13 @@ for (const [key, config] of Object.entries(PROVIDERS)) {
expect(fs.existsSync(refPath)).toBe(true);
});
test('should emit skillsVersion in generated skill frontmatter', () => {
const skills = [{ name: 'test', description: 'Test', body: 'Body' }];
transform(skills, TEST_DIR, { skillsVersion: '1.2.3-test' });
const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
expect(parsed.frontmatter.version).toBe('1.2.3-test');
});
// Field-specific tests based on provider config
if (config.frontmatterFields.includes('user-invocable')) {
test('should emit user-invocable for user-invocable skills', () => {
+77
View File
@@ -154,6 +154,67 @@ describe('live-browser.js regression guards', () => {
);
});
it('keeps the Steer pill from clipping its label or the global bar exit control', () => {
assert.match(
SOURCE,
/const PAGE_CHAT_COLLAPSED_W = '104px';/,
'collapsed Steer pill should reserve enough room for icon, "Steer", and voice button',
);
assert.match(
SOURCE,
/function pageChatExpandedWidth\(\)[\s\S]{0,520}?window\.innerWidth - 16 - nonChatWidth[\s\S]{0,220}?Math\.max\(pageChatCollapsedWidthPx\(\), Math\.min\(PAGE_CHAT_EXPANDED_MAX_W, available\)\)/,
'expanded Steer width should reserve viewport room for the rest of the global bar',
);
assert.match(
SOURCE,
/syncGlobalBarExpandedLabels\(false\);[\s\S]{0,80}?pageChatEl\.style\.width = pageChatExpandedWidth\(\);/,
'opening Steer should collapse inactive mode labels before measuring input width',
);
assert.match(
SOURCE,
/maxWidth: 'calc\(100vw - 16px\)'[\s\S]{0,80}?boxSizing: 'border-box'/,
'global bar should be constrained to the viewport instead of clipping the exit control offscreen',
);
assert.match(
SOURCE,
/globalBarEl = el\('div', \{[\s\S]{0,360}?width: 'max-content'/,
'fixed-position global bar must use max-content sizing before maxWidth clamps it, or narrow panes clip the exit button',
);
assert.match(
SOURCE,
/const inner = el\('div', \{[\s\S]{0,220}?flex: '0 0 auto'/,
'global bar inner controls must not flex-shrink and crop hover labels',
);
assert.match(
SOURCE,
/function makeIconBtn[\s\S]{0,360}?flex: '0 0 auto'[\s\S]{0,80}?minWidth: '30px'/,
'global bar icon buttons must keep stable hitboxes when Steer expands',
);
assert.match(
SOURCE,
/applyGlobalBarLabelState\(expandInactive, pageChatExpanded\)/,
'expanded Steer should force labels closed without shrinking the icons',
);
});
it('does not autofocus the steering chat while a page editable is focused', () => {
assert.match(
SOURCE,
/function isPageEditableElement\(el\) \{[\s\S]{0,160}?own\(el\)[\s\S]{0,160}?\^\(INPUT\|TEXTAREA\|SELECT\)\$[\s\S]{0,80}?el\.isContentEditable/,
'page-owned inputs, textareas, selects, and contenteditables must be recognized before steer focus recovery runs',
);
assert.match(
SOURCE,
/function isPageEditableActive\(\) \{[\s\S]{0,120}?activeElementDeep\(\)[\s\S]{0,120}?isPageEditableElement\(active\) && !isInlineEditActive\(active\)/,
'auto-focus recovery must check the deep active element instead of only host text selection',
);
assert.match(
SOURCE,
/function shouldSteerAutoFocus\(\) \{[\s\S]{0,160}?&& !isPageEditableActive\(\)/,
'steer chat auto-focus must back off while the page owns an editable caret',
);
});
it('pins edit badge button metrics instead of inheriting host button chrome', () => {
const start = SOURCE.indexOf('const calloutStyle = (color, borderColor) => ({');
const end = SOURCE.indexOf(' });', start);
@@ -345,6 +406,22 @@ describe('live-browser.js regression guards', () => {
);
});
it('lets page editables keep Enter and arrow keydown events', () => {
const start = SOURCE.indexOf('function handleKeyDown(e)');
const pendingApplyStart = SOURCE.indexOf('if (pendingApplyInFlight)', start);
const guardSource = SOURCE.slice(start, pendingApplyStart);
assert.match(
guardSource,
/isPageEditableElement\(deepActive\) && !isInlineEditActive\(deepActive\)[\s\S]{0,40}?return;/,
'page-owned editables must short-circuit global key handling before variant navigation or accept handling',
);
assert.match(
guardSource,
/e\.target\.isContentEditable && isInlineEditActive\(e\.target\)/,
'impeccable inline edit rows must keep their existing Escape-cancel path',
);
});
it('configure input Escape tears down annotation overlay before returning to picking', () => {
// The configure prompt auto-focuses. While focused, the global keydown
// handler bails on own() inputs, so this local Escape path must hide the
+138 -2
View File
@@ -139,6 +139,7 @@ describe('skills install: already-installed detection', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
execSync('git init', { cwd: tmp });
createFakeSkills(tmp);
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
// Seed the canonical hook target so the already-installed path sees the hook
// wired up and doesn't try to repair it (which would need the bundle).
writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({
@@ -147,12 +148,38 @@ describe('skills install: already-installed detection', () => {
] }] },
}));
const output = run('skills install -y', { cwd: tmp });
const output = run('skills install -y', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('already installed');
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('already-installed projects keep working when the update check is offline', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-offline-installed-'));
execSync('git init', { cwd: tmp });
createFakeSkills(tmp, ['impeccable'], ['.claude']);
writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({
hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' },
] }] },
}));
const output = run('skills install -y --providers=claude', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: join(tmp, 'missing-bundle') },
});
expect(output).toContain('already installed');
expect(output).toContain('Could not check for skill updates');
expect(output).toContain('Existing skills were left unchanged.');
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('detects prefixed i-impeccable', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
execSync('git init', { cwd: tmp });
@@ -160,6 +187,7 @@ describe('skills install: already-installed detection', () => {
const skillDir = join(tmp, '.cursor', 'skills', 'i-impeccable');
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: i-impeccable\n---\n');
const bundleRoot = createFakeUniversalBundle(tmp, ['.cursor']);
// Seed the hook so the already-installed path sees it wired up and doesn't
// try to repair it (which would need the bundle).
writeFileSync(join(tmp, '.cursor', 'hooks.json'), JSON.stringify({
@@ -167,7 +195,10 @@ describe('skills install: already-installed detection', () => {
hooks: { preToolUse: [{ command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"' }] },
}));
const output = run('skills install -y', { cwd: tmp });
const output = run('skills install -y', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('already installed');
rmSync(tmp, { recursive: true, force: true });
@@ -343,6 +374,36 @@ describe('skills link: submodule installs', () => {
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('plain install leaves linked installs on the submodule path', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-install-'));
execSync('git init', { cwd: tmp });
createFakeLinkSource(tmp);
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.cursor']);
run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
const linkedDest = join(tmp, '.claude', 'skills', 'impeccable');
const before = readlinkSync(linkedDest);
const copiedDest = join(tmp, '.cursor', 'skills', 'impeccable');
mkdirSync(join(copiedDest, 'scripts'), { recursive: true });
writeFileSync(join(copiedDest, 'SKILL.md'), '---\nname: impeccable\nstale: true\n---\nOld content.\n');
writeFileSync(join(copiedDest, 'scripts', 'context.mjs'), 'console.log("old broken script");\n');
const output = run('skills install -y --providers=claude,cursor --no-hooks', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Linked skills found in: .claude');
expect(output).toContain('Continuing with copied installs in: .cursor');
expect(output).toContain('Updated');
expect(readlinkSync(linkedDest)).toBe(before);
expect(lstatSync(linkedDest).isSymbolicLink()).toBe(true);
expect(readFileSync(join(copiedDest, 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
expect(readFileSync(join(copiedDest, 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("local bundle context");\n');
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('deduplicates providers that share one skills directory', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-shared-'));
execSync('git init', { cwd: tmp });
@@ -869,6 +930,81 @@ describe('skills install/update: local universal bundle e2e', () => {
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('skills update refreshes script-only bundle changes when SKILL.md is unchanged', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-script-only-update-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
run('skills install -y --providers=claude --no-hooks', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
const scriptPath = join(tmp, '.claude', 'skills', 'impeccable', 'scripts', 'context.mjs');
writeFileSync(scriptPath, 'console.log("old broken script");\n');
const output = run('skills update -y --no-hooks', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Updated');
expect(readFileSync(scriptPath, 'utf8')).toBe('console.log("local bundle context");\n');
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('plain install refreshes an already-installed stale skill', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-existing-install-refresh-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
const skillDir = join(tmp, '.claude', 'skills', 'impeccable');
mkdirSync(join(skillDir, 'scripts'), { recursive: true });
writeFileSync(
join(skillDir, 'SKILL.md'),
readFileSync(join(bundleRoot, '.claude', 'skills', 'impeccable', 'SKILL.md'), 'utf8')
);
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("old broken script");\n');
const output = run('skills install -y --providers=claude --no-hooks', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('already installed');
expect(output).toContain('Updated');
expect(readFileSync(join(skillDir, 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("local bundle context");\n');
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('plain install only refreshes selected copied providers', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-existing-install-scope-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.cursor']);
for (const provider of ['.claude', '.cursor']) {
const skillDir = join(tmp, provider, 'skills', 'impeccable');
mkdirSync(join(skillDir, 'scripts'), { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: impeccable\nstale: ${provider}\n---\nOld content.\n`);
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), `console.log("old ${provider} script");\n`);
}
const output = run('skills install -y --providers=claude --no-hooks', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('already installed');
expect(output).toContain('Updated');
expect(readFileSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
expect(readFileSync(join(tmp, '.cursor', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('stale: .cursor');
expect(readFileSync(join(tmp, '.cursor', 'skills', 'impeccable', 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("old .cursor script");\n');
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('skills update --no-hooks refreshes skills without touching malformed hook manifests', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-no-hooks-'));
execSync('git init', { cwd: tmp });