diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fa9e47420..81df147e2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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.6.0", + "version": "3.7.0", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b30e62ce3..637c1dfd8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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.6.0", + "version": "3.7.0", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" diff --git a/.impeccable/config.json b/.impeccable/config.json index 2e2dad656..45ca0e911 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -1,11 +1,15 @@ { - "hook": { - "enabled": true, + "detector": { "ignoreRules": [], "ignoreFiles": [ "tests/fixtures/**", "tests/detect-antipatterns.test.js", - "site/pages/slop/**" + "site/pages/slop/**", + "site/data/anti-patterns-catalog.js", + "site/pages/shader-lab/**", + "site/scripts/demos/commands/**", + "site/styles/skill-demos.css", + "site/styles/slop-kinpaku.css" ], "ignoreValues": [ { @@ -13,8 +17,65 @@ "value": "bounce-ball", "createdAt": "2026-06-15T04:15:03.164Z", "reason": "User confirmed ball bounce animation is intentional" + }, + { + "rule": "design-system-color", + "value": "*", + "files": [ + "site/styles/home-rebuild.css" + ], + "createdAt": "2026-06-15T23:37:38.170Z", + "reason": "AURELIA hotel picker is intentionally a foreign boutique-hotel palette inside the live picker demo" + }, + { + "rule": "design-system-color", + "value": "*", + "files": [ + "site/styles/main.css" + ], + "createdAt": "2026-06-15T23:37:38.170Z", + "reason": "Generic AI slop card intentionally uses off-system colors for the before-state comparison" + }, + { + "rule": "design-system-color", + "value": "*", + "files": [ + "site/styles/home-kinpaku.css" + ], + "createdAt": "2026-06-15T23:37:38.170Z", + "reason": "Homepage before-state slop demo intentionally uses off-system purple/magenta colors" + }, + { + "rule": "design-system-color", + "value": "*", + "files": [ + "site/styles/design-system.css" + ], + "createdAt": "2026-06-15T23:37:38.170Z", + "reason": "Design-system comparison intentionally shows an off-system before-state" + }, + { + "rule": "design-system-color", + "value": "*", + "files": [ + "site/styles/workflow.css" + ], + "createdAt": "2026-06-15T23:37:38.170Z", + "reason": "Generic slop card intentionally uses off-system purple colors for the before-state comparison" + }, + { + "rule": "design-system-font", + "value": "*", + "files": [ + "site/styles/workflow.css" + ], + "createdAt": "2026-06-15T23:37:38.170Z", + "reason": "Generic slop card intentionally uses Inter for the before-state comparison" } - ], + ] + }, + "hook": { + "enabled": true, "limits": { "maxFindings": 5, "maxChars": 8000 diff --git a/.impeccable/design.json b/.impeccable/design.json index 9a16a8bb7..0d88ce73f 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "generatedAt": "2026-05-26T00:00:00Z", + "generatedAt": "2026-06-15T23:30:35.569Z", "title": "Design System: Impeccable", "extensions": { "colorMeta": { @@ -10,14 +10,18 @@ "canonical": "oklch(84% 0.19 80.46)", "description": "Primary accent. CTAs, wordmark, active state, live picker borders, key rules.", "tonalRamp": [ - "oklch(18% 0.04 78)", - "oklch(28% 0.07 79)", - "oklch(38% 0.10 80)", - "oklch(48% 0.12 80)", - "oklch(58% 0.14 80)", - "oklch(68% 0.16 80)", - "oklch(78% 0.18 80)", - "oklch(88% 0.12 82)" + "oklch(22% 0.04 78)", + "oklch(34% 0.06 78)", + "oklch(48% 0.08 78)", + "oklch(61% 0.085 78)", + "oklch(72% 0.105 82)", + "oklch(78% 0.12 82)", + "oklch(84% 0.075 84)", + "oklch(89% 0.055 84)", + "oklch(95% 0.04 84)", + "oklch(98% 0.04 84)", + "oklch(94% 0.07 82)", + "oklch(98% 0.035 84)" ] }, "verdigris-patina": { @@ -26,14 +30,13 @@ "canonical": "oklch(70% 0.12 188)", "description": "Secondary accent. Improved states, live indicators, focus rings, contrast against gold.", "tonalRamp": [ - "oklch(18% 0.04 188)", - "oklch(28% 0.06 188)", - "oklch(38% 0.08 188)", - "oklch(48% 0.09 188)", - "oklch(58% 0.10 188)", - "oklch(68% 0.11 188)", - "oklch(78% 0.10 188)", - "oklch(88% 0.06 188)" + "oklch(22% 0.04 188)", + "oklch(49% 0.08 188)", + "oklch(58% 0.085 185)", + "oklch(70% 0.105 190)", + "oklch(71% 0.082 184)", + "oklch(76% 0.088 190)", + "oklch(82% 0.07 188)" ] }, "lacquer-black": { @@ -47,46 +50,125 @@ "oklch(11% 0.006 95)", "oklch(15% 0.008 95)", "oklch(19% 0.008 95)", - "oklch(28% 0.010 95)", - "oklch(40% 0.012 82)", - "oklch(55% 0.014 82)" + "oklch(22% 0.008 95)", + "oklch(34% 0.014 82)", + "oklch(58% 0.012 95)" ] }, - "lacquer-deep": { + "neutral-text": { "role": "neutral", - "displayName": "Lacquer Deep", - "canonical": "oklch(4% 0.004 95)", - "description": "Deepest inset surfaces, live picker bar fill, CTA text on gold." + "displayName": "Neutral Text Ramp", + "canonical": "oklch(88% 0 0)", + "description": "Neutral white/gray text and utility stops shared by dark docs, live overlay, and light-mode chrome.", + "tonalRamp": [ + "oklch(100% 0 0)", + "oklch(99% 0 0)", + "oklch(98.5% 0 0)", + "oklch(98% 0 0)", + "oklch(96% 0 0)", + "oklch(94% 0 0)", + "oklch(91% 0 0)", + "oklch(88% 0 0)", + "oklch(85% 0 0)", + "oklch(80% 0 0)", + "oklch(75% 0 0)", + "oklch(72% 0 0)", + "oklch(65% 0 0)", + "oklch(62% 0 0)", + "oklch(58% 0 0)", + "oklch(55% 0 0)", + "oklch(45% 0 0)", + "oklch(42% 0 0)", + "oklch(40% 0 0)", + "oklch(35% 0 0)", + "oklch(34% 0 0)", + "oklch(30% 0 0)", + "oklch(25% 0 0)", + "oklch(24% 0 0)", + "oklch(22% 0 0)" + ] }, - "raised-lacquer": { + "light-paper": { "role": "neutral", - "displayName": "Raised Lacquer", - "canonical": "oklch(11% 0.006 95)", - "description": "Panels, cards, inputs, demo frames." + "displayName": "Light Mode Paper", + "canonical": "oklch(97% 0.012 95)", + "description": "Light theme surfaces, ink, and rules from site/styles/kinpaku-tokens.css.", + "tonalRamp": [ + "oklch(99% 0.008 95)", + "oklch(97.5% 0.004 95)", + "oklch(97% 0.012 95)", + "oklch(96% 0.012 95)", + "oklch(94% 0.014 95)", + "oklch(92% 0.014 95)", + "oklch(91% 0.012 95)", + "oklch(88% 0.014 95)", + "oklch(72% 0.02 95)", + "oklch(65% 0.01 95)", + "oklch(55% 0.012 95)", + "oklch(45% 0.015 95)", + "oklch(30% 0.02 95)", + "oklch(25% 0.02 95)", + "oklch(25% 0.018 95)", + "oklch(18% 0.02 95)" + ] }, - "champagne": { - "role": "neutral", - "displayName": "Champagne", - "canonical": "oklch(84% 0.035 82)", - "description": "Headlines, strong labels, picker bar default text." + "command-category": { + "role": "data-viz", + "displayName": "Command Category Colors", + "canonical": "oklch(86% 0.075 82)", + "description": "Homepage proof modules use a constrained category palette for command groups, not generic decoration.", + "tonalRamp": [ + "oklch(86% 0.075 82)", + "oklch(76% 0.088 190)", + "oklch(89% 0.055 84)", + "oklch(84% 0.055 84)", + "oklch(88% 0.047 84)", + "oklch(61% 0.085 78)", + "oklch(70% 0.096 72)", + "oklch(82% 0.095 76)", + "oklch(58% 0.085 185)", + "oklch(71% 0.082 184)", + "oklch(58% 0.028 82)", + "oklch(71% 0.028 82)" + ] }, - "text-warm": { - "role": "neutral", - "displayName": "Warm Text", - "canonical": "oklch(81% 0.03 82)", - "description": "Body copy on dark lacquer surfaces." - }, - "text-muted": { - "role": "neutral", - "displayName": "Muted Text", - "canonical": "oklch(63% 0.024 82)", - "description": "Captions, meta, inactive picker controls." + "terminal-chrome": { + "role": "utility", + "displayName": "Terminal Chrome", + "canonical": "#ff5f56", + "description": "Conventional faux terminal traffic-light dots.", + "tonalRamp": [ + "#ff5f56", + "#ffbd2e", + "#27c93f" + ] }, "vermilion-warning": { "role": "state", "displayName": "Vermilion Warning", "canonical": "oklch(58% 0.15 35)", - "description": "Errors, anti-pattern warnings, live picker exit hover." + "description": "Errors, anti-pattern warnings, live picker exit hover.", + "tonalRamp": [ + "oklch(52% 0.16 35)", + "oklch(46% 0.13 35)", + "oklch(42% 0.13 35)", + "oklch(22% 0.04 35)", + "oklch(58% 0.15 35)" + ] + }, + "positive-success": { + "role": "state", + "displayName": "Positive Success", + "canonical": "oklch(45% 0.18 145)", + "description": "Success and accepted states: confirmation rows, diff additions, and positive status badges.", + "tonalRamp": [ + "oklch(40% 0.10 145)", + "oklch(45% 0.18 145)", + "oklch(58% 0.12 145)", + "oklch(75% 0.12 145)", + "oklch(95% 0.05 145)", + "oklch(96% 0.04 145)" + ] } }, "typographyMeta": { @@ -149,9 +231,49 @@ } ], "breakpoints": [ - { "name": "md", "value": "980px" }, - { "name": "lg", "value": "1080px" } - ] + { + "name": "md", + "value": "980px" + }, + { + "name": "lg", + "value": "1080px" + } + ], + "roundedMeta": { + "code": { + "value": "3px", + "purpose": "Inline code, terminal chips, tiny badges." + }, + "controlSm": { + "value": "5px", + "purpose": "Compact icon and chip controls." + }, + "controlMd": { + "value": "7px", + "purpose": "Live overlay buttons and segmented controls." + }, + "xl": { + "value": "10px", + "purpose": "Picker bars, compact frames, and small visual shells." + }, + "panel": { + "value": "12px", + "purpose": "Larger panels and demo canvases." + }, + "overlay": { + "value": "14px", + "purpose": "Detached overlay panels." + }, + "visualFrame": { + "value": "16px", + "purpose": "Large illustrative frames only." + }, + "pill": { + "value": "999px", + "purpose": "Tags, toggles, and circular controls." + } + } }, "components": [ { @@ -215,17 +337,61 @@ "Small radii, restrained borders, and almost no decorative shadow." ], "rules": [ - { "name": "The Gold Carries Brand Rule", "section": "colors", "body": "Kinpaku gold is the primary brand signal. If a single accent must represent Impeccable, use gold, not magenta or cyan." }, - { "name": "The Patina Has Meaning Rule", "section": "colors", "body": "Verdigris is secondary. It marks improvement, live state, or contrast against gold. Do not use it as a generic decoration field." }, - { "name": "The Texture Budget Rule", "section": "colors", "body": "Leaf and patina textures are for brand-bearing moments: hero seams, CTA fills, dividers, major swatches, and select system modules. Generic cards stay mostly flat." }, - { "name": "The OKLCH-Only Rule", "section": "colors", "body": "New colors are declared in OKLCH. Hex appears only inside third-party examples or imported assets." }, - { "name": "The Weight-Inversion Rule", "section": "typography", "body": "Section h2s read heavier (600) than the hero h1 (300). This is deliberate: the hero is elegant and thin so the page can breathe; section anchors carry more weight to ground each block." }, - { "name": "The Two-Face Rule", "section": "typography", "body": "Display sizes use Alumni Sans Pinstripe. Anything sized below 1.2rem uses Albert Sans. Pinstripe at small sizes loses its identity and reads as a bad rendering." }, - { "name": "The Tracked Labels Are Short Rule", "section": "typography", "body": "Tracked uppercase labels are for short system markers. Do not write full sentences in tracked caps." }, - { "name": "The Dark Type Needs Air Rule", "section": "typography", "body": "Body text on lacquer uses line-height 1.65 to 1.8 and a max width of 65 to 75ch." }, - { "name": "The Hairline First Rule", "section": "elevation", "body": "Use 1px gold hairlines before adding shadow." }, - { "name": "The No Glass Rule", "section": "elevation", "body": "Translucency can exist in overlays, but decorative blur/glass panels are not part of this system." }, - { "name": "The Picker Is Brand Rule", "section": "components", "body": "Live mode UI is Impeccable product chrome, not host-page chrome. It always ships the full kinpaku border, lacquer-deep fill, and carved-tile mark." } + { + "name": "The Gold Carries Brand Rule", + "section": "colors", + "body": "Kinpaku gold is the primary brand signal. If a single accent must represent Impeccable, use gold, not magenta or cyan." + }, + { + "name": "The Patina Has Meaning Rule", + "section": "colors", + "body": "Verdigris is secondary. It marks improvement, live state, or contrast against gold. Do not use it as a generic decoration field." + }, + { + "name": "The Texture Budget Rule", + "section": "colors", + "body": "Leaf and patina textures are for brand-bearing moments: hero seams, CTA fills, dividers, major swatches, and select system modules. Generic cards stay mostly flat." + }, + { + "name": "The OKLCH-Only Rule", + "section": "colors", + "body": "New colors are declared in OKLCH. Hex appears only inside third-party examples or imported assets." + }, + { + "name": "The Weight-Inversion Rule", + "section": "typography", + "body": "Section h2s read heavier (600) than the hero h1 (300). This is deliberate: the hero is elegant and thin so the page can breathe; section anchors carry more weight to ground each block." + }, + { + "name": "The Two-Face Rule", + "section": "typography", + "body": "Display sizes use Alumni Sans Pinstripe. Anything sized below 1.2rem uses Albert Sans. Pinstripe at small sizes loses its identity and reads as a bad rendering." + }, + { + "name": "The Tracked Labels Are Short Rule", + "section": "typography", + "body": "Tracked uppercase labels are for short system markers. Do not write full sentences in tracked caps." + }, + { + "name": "The Dark Type Needs Air Rule", + "section": "typography", + "body": "Body text on lacquer uses line-height 1.65 to 1.8 and a max width of 65 to 75ch." + }, + { + "name": "The Hairline First Rule", + "section": "elevation", + "body": "Use 1px gold hairlines before adding shadow." + }, + { + "name": "The No Glass Rule", + "section": "elevation", + "body": "Translucency can exist in overlays, but decorative blur/glass panels are not part of this system." + }, + { + "name": "The Picker Is Brand Rule", + "section": "components", + "body": "Live mode UI is Impeccable product chrome, not host-page chrome. It always ships the full kinpaku border, lacquer-deep fill, and carved-tile mark." + } ], "dos": [ "Do use kinpaku gold as the primary brand color.", diff --git a/DESIGN.md b/DESIGN.md index 808ab90fb..d8829b9d1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -24,19 +24,83 @@ colors: text-faint: "oklch(62% 0 0)" # subdued text-mute-deep: "oklch(52% 0 0)" # disabled + # Neutral ramp and light-mode paper/ink tokens. These mirror html.light in + # site/styles/kinpaku-tokens.css plus neutral utility stops used in docs and + # the live overlay. + neutral-100: "oklch(100% 0 0)" + neutral-99: "oklch(99% 0 0)" + neutral-98: "oklch(98% 0 0)" + neutral-96: "oklch(96% 0 0)" + neutral-94: "oklch(94% 0 0)" + neutral-85: "oklch(85% 0 0)" + neutral-80: "oklch(80% 0 0)" + neutral-75: "oklch(75% 0 0)" + neutral-55: "oklch(55% 0 0)" + neutral-45: "oklch(45% 0 0)" + neutral-35: "oklch(35% 0 0)" + neutral-34: "oklch(34% 0 0)" + neutral-30: "oklch(30% 0 0)" + neutral-25: "oklch(25% 0 0)" + neutral-24: "oklch(24% 0 0)" + neutral-22: "oklch(22% 0 0)" + light-paper: "oklch(97% 0.012 95)" + light-paper-deep: "oklch(94% 0.014 95)" + light-paper-raised: "oklch(99% 0.008 95)" + light-graphite: "oklch(91% 0.012 95)" + light-graphite-2: "oklch(88% 0.014 95)" + light-ink: "oklch(18% 0.02 95)" + light-text: "oklch(25% 0.018 95)" + light-muted: "oklch(45% 0.015 95)" + light-faint: "oklch(55% 0.012 95)" + light-disabled: "oklch(65% 0.01 95)" + light-rule: "oklch(25% 0.02 95)" + # Gold ramp kinpaku-pale: "oklch(86% 0.07 84)" # hover lift, pale fills kinpaku-rich: "oklch(77% 0.13 82)" # active CTA, severity-medium kinpaku-deep: "oklch(61% 0.085 78)" # borders against the brand + kinpaku-rule: "oklch(78% 0.12 82)" # alpha-friendly rule/glow base + kinpaku-rule-rich: "oklch(77% 0.14 82)" # secondary button soft fill base + kinpaku-glint: "oklch(95% 0.04 84)" # leaf texture highlight overlay + kinpaku-glint-bright: "oklch(98% 0.04 84)" # animated leaf sheen highlight + kinpaku-glint-rich: "oklch(94% 0.07 82)" # warmer leaf sheen edge + kinpaku-glint-soft: "oklch(98% 0.035 84)" # hover glint on gold CTAs gold-hairline: "oklch(78% 0 0 / 0.16)" # default rule — neutral (name kept for compat) gold-hairline-strong: "oklch(74% 0.09 82 / 0.6)" # active rule — gold # Patina ramp patina-pale: "oklch(82% 0.07 188)" # hover lift on patina patina-deep: "oklch(49% 0.08 188)" # deep oxide, dark variants + patina-rule: "oklch(70% 0.105 190)" # alpha-friendly glow/rule base + patina-text: "oklch(76% 0.088 190)" # small readable patina text + + # Legacy command category colors still used by homepage proof modules. + category-create-text: "oklch(86% 0.075 82)" + category-evaluate-text: "oklch(76% 0.088 190)" + category-refine-bg: "oklch(89% 0.055 84)" + category-refine-border: "oklch(84% 0.055 84)" + category-refine-text: "oklch(88% 0.047 84)" + category-simplify-bg: "oklch(61% 0.085 78)" + category-simplify-border: "oklch(70% 0.096 72)" + category-simplify-text: "oklch(82% 0.095 76)" + category-harden-border: "oklch(58% 0.085 185)" + category-harden-text: "oklch(71% 0.082 184)" + category-system-border: "oklch(58% 0.028 82)" + category-system-text: "oklch(71% 0.028 82)" + + # Conventional terminal chrome. Used only for faux terminal window dots. + terminal-red: "#ff5f56" + terminal-yellow: "#ffbd2e" + terminal-green: "#27c93f" # State (warning only, used sparingly) vermilion-warning: "oklch(58% 0.15 35)" + vermilion-warning-light: "oklch(52% 0.16 35)" + success: "oklch(45% 0.18 145)" + success-on-paper: "oklch(40% 0.10 145)" + success-bg: "oklch(58% 0.12 145)" + success-border: "oklch(75% 0.12 145)" + success-soft: "oklch(95% 0.05 145)" typography: wordmark: @@ -84,9 +148,17 @@ typography: rounded: none: "0" xs: "2px" + code: "3px" sm: "4px" + control-sm: "5px" md: "6px" + control-md: "7px" lg: "8px" + xl: "10px" + "2xl": "12px" + "3xl": "14px" + "4xl": "16px" + pill: "999px" spacing: xs: "8px" diff --git a/README.md b/README.md index c829b08ec..f93dd628c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Impeccable -Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 41 deterministic detector rules for AI-generated frontend design. +Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 44 deterministic detector rules for AI-generated frontend design. > **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style). @@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th Impeccable adds: - **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components. - **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more. -- **41 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. +- **44 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. ## What's Included @@ -31,7 +31,7 @@ Start every new project with: /impeccable init ``` -`init` asks whether the surface is brand (marketing, landing, portfolio) or product (app UI, dashboard, tool), then writes project context that every later command reads. +`init` asks whether the surface is brand (marketing, landing, portfolio) or product (app UI, dashboard, tool), then writes design context that every later command reads. ### 23 Commands @@ -270,12 +270,14 @@ Installed hook surfaces: The installer preserves unrelated hook entries and settings. If a hook manifest is malformed, install/update aborts by default; rerun with `--force` to back up the malformed file as `.bak` and replace it. -On an interactive `install`/`update`, Impeccable explains the hook and offers to install it (default yes). Your choice is remembered per-developer in the gitignored `.impeccable/config.local.json`, so you are not asked again; `--no-hooks` skips it for that run without recording anything. Hook settings (enable/ignore rules, etc.) live under the `hook` key of `.impeccable/config.json`, managed with `/impeccable hooks`. +On an interactive `install`/`update`, Impeccable explains the hook and offers to install it (default yes). Your choice is remembered per-developer in the gitignored `.impeccable/config.local.json`, so you are not asked again; `--no-hooks` skips it for that run without recording anything. Hook lifecycle settings live under the `hook` key of `.impeccable/config.json`; detector ignores live under `detector`, shared by `/impeccable hooks` and `npx impeccable detect`. For debugging, set `hook.auditLog` in `.impeccable/config.json` to a path (or the legacy `IMPECCABLE_HOOK_LOG` env var) to write one NDJSON line per hook invocation. Leave it unset for normal use. Codex requires one platform step that Impeccable cannot safely skip: open `/hooks` after install or update and approve the project hook. There is no Codex marketplace/plugin install flow for this hook. +Full hook docs: [impeccable.style/docs/hooks](https://impeccable.style/docs/hooks). + Manual copy commands are fallback/debug instructions. The normal path is: ```bash @@ -291,10 +293,18 @@ Impeccable includes a standalone CLI for detecting anti-patterns without an AI h npx impeccable detect src/ # scan a directory npx impeccable detect index.html # scan an HTML file npx impeccable detect https://example.com # scan a URL (Puppeteer) -npx impeccable detect --fast --json . # regex-only, JSON output +npx impeccable detect --json . # CI-friendly JSON output +npx impeccable detect --no-config src/ # raw scan, ignoring project config/context +npx impeccable ignores list # show detector ignores +npx impeccable ignores add-file "src/legacy/**" +npx impeccable ignores add-value overused-font Inter --reason "Brand font" ``` -The detector catches 41 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more). +The detector catches 44 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more). + +By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution. + +Full detector docs: [impeccable.style/docs/detector](https://impeccable.style/docs/detector). ## Supported Tools diff --git a/README.npm.md b/README.npm.md index 2cf877f24..38fcaa073 100644 --- a/README.npm.md +++ b/README.npm.md @@ -1,6 +1,6 @@ # Impeccable CLI -Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 41 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems. +Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 44 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems. ## Quick Start @@ -56,7 +56,7 @@ npx impeccable detect --fast src/ **Quality**: tiny body text, cramped padding, long line lengths, small touch targets -41 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). +44 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). ## Exit Codes diff --git a/astro.config.mjs b/astro.config.mjs index 8dd52a511..3a45f8976 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,9 +1,16 @@ import { defineConfig } from 'astro/config'; +import { impeccableShikiThemes } from './site/lib/impeccable-shiki-theme.mjs'; export default defineConfig({ srcDir: './site', publicDir: './site/public', output: 'static', + markdown: { + shikiConfig: { + themes: impeccableShikiThemes, + defaultColor: false, + }, + }, devToolbar: { enabled: false, }, diff --git a/cli/bin/cli.js b/cli/bin/cli.js index ebbee1817..f47509ba9 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -5,6 +5,7 @@ * * Usage: * npx impeccable detect [file-or-dir-or-url...] + * npx impeccable ignores * npx impeccable help|install|update * npx impeccable --help */ @@ -25,6 +26,7 @@ async function main() { Commands: detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues + ignores Manage detector ignore rules, files, and values help List all available skills and commands install Install impeccable skills into your project or global harness link Symlink skills from a local checkout or submodule @@ -50,6 +52,9 @@ Compatibility: process.argv = [process.argv[0], process.argv[1], ...args.slice(1)]; const { detectCli } = await import('../engine/detect-antipatterns.mjs'); await detectCli(); + } else if (command === 'ignores' || command === 'ignore') { + const { run } = await import('./commands/ignores.mjs'); + await run(args.slice(1)); } else if (command === 'skills') { const { run } = await import('./commands/skills.mjs'); await run(args.slice(1)); diff --git a/cli/bin/commands/ignores.mjs b/cli/bin/commands/ignores.mjs new file mode 100644 index 000000000..9eb4696ed --- /dev/null +++ b/cli/bin/commands/ignores.mjs @@ -0,0 +1,330 @@ +import path from 'node:path'; + +import { + getConfigPath, + getLocalConfigPath, + normalizeIgnoreValue, + readDetectionConfig, + readRawDetectionConfig, + writeDetectionConfig, +} from '../../lib/impeccable-config.mjs'; + +const ACTION_ALIASES = new Map([ + ['status', 'list'], + ['ls', 'list'], + ['list', 'list'], + ['add-rule', 'add-rule'], + ['ignore-rule', 'add-rule'], + ['add-file', 'add-file'], + ['ignore-file', 'add-file'], + ['add-value', 'add-value'], + ['ignore-value', 'add-value'], + ['update-value', 'add-value'], + ['remove-rule', 'remove-rule'], + ['rm-rule', 'remove-rule'], + ['remove-file', 'remove-file'], + ['rm-file', 'remove-file'], + ['remove-value', 'remove-value'], + ['rm-value', 'remove-value'], + ['clear', 'clear'], +]); + +function printUsage() { + console.log(`Usage: impeccable ignores [options] + +Manage detector ignores in .impeccable config. + +Actions: + list Show merged, shared, and local ignores + add-rule [--all-values] Ignore a rule + add-file Ignore files by glob + add-value Ignore one rule/value pair + remove-rule Remove a rule ignore + remove-file Remove a file ignore + remove-value Remove a rule/value ignore + clear Clear detector ignores in the selected scope + +Scope: + --shared Write .impeccable/config.json (default) + --local Write .impeccable/config.local.json + --all For remove/clear, apply to shared and local + +Value options: + --file Scope add-value/remove-value to a file glob + --reason Store or update a reason on add-value + +Examples: + impeccable ignores add-file "src/legacy/**" + impeccable ignores add-value overused-font Inter --reason "Brand font" + impeccable ignores add-value design-system-color "*" --file "src/demo.css" + impeccable ignores remove-value overused-font Inter`); +} + +function parseScope(args, { allowAll = false } = {}) { + const rest = []; + let local = false; + let shared = false; + let all = false; + for (const arg of args) { + if (arg === '--local') local = true; + else if (arg === '--shared') shared = true; + else if (arg === '--all') all = true; + else rest.push(arg); + } + if ([local, shared, all].filter(Boolean).length > 1) { + throw new Error(`Pass only one scope flag: --shared${allowAll ? ', --local, or --all' : ' or --local'}`); + } + if (all && !allowAll) throw new Error('--all is only supported for remove and clear actions'); + return { local, all, rest }; +} + +function parseValueArgs(args, { allowUnscopedWildcard = false } = {}) { + const positionals = []; + const files = []; + let reason = ''; + + for (let i = 0; i < args.length; i++) { + const arg = String(args[i] || ''); + if (arg === '--reason') { + const chunks = []; + while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) chunks.push(args[++i]); + reason = chunks.join(' ').trim(); + } else if (arg.startsWith('--reason=')) { + reason = arg.slice('--reason='.length).trim(); + } else if (arg === '--file' || arg === '--files') { + if (i + 1 >= args.length) throw new Error(`${arg} requires a glob`); + files.push(String(args[++i]).trim()); + } else if (arg.startsWith('--file=')) { + files.push(arg.slice('--file='.length).trim()); + } else if (arg.startsWith('--files=')) { + files.push(arg.slice('--files='.length).trim()); + } else if (arg.startsWith('--')) { + throw new Error(`Unknown add-value flag: ${arg}`); + } else { + positionals.push(arg); + } + } + + const [rule, ...valueParts] = positionals; + const value = normalizeIgnoreValue(valueParts.join(' ')); + if (!rule || !value) throw new Error('Pass a rule id and value, e.g. impeccable ignores add-value overused-font Inter'); + const scopedFiles = Array.from(new Set(files.filter(Boolean))); + if (value === '*' && scopedFiles.length === 0 && !allowUnscopedWildcard) { + throw new Error('Wildcard value ignores must be scoped with --file .'); + } + return { + rule: String(rule).trim().toLowerCase(), + value, + files: scopedFiles, + reason, + }; +} + +function formatValues(values) { + if (!values.length) return '(none)'; + return values + .map((entry) => { + const fileSuffix = Array.isArray(entry.files) && entry.files.length + ? ` [${entry.files.join(', ')}]` + : ''; + const reasonSuffix = entry.reason ? ` - ${entry.reason}` : ''; + return `${entry.rule}=${entry.value}${fileSuffix}${reasonSuffix}`; + }) + .join(', '); +} + +function formatConfig(label, config) { + return [ + `${label}:`, + ` ignoreRules: ${config.ignoreRules.length ? config.ignoreRules.join(', ') : '(none)'}`, + ` ignoreFiles: ${config.ignoreFiles.length ? config.ignoreFiles.join(', ') : '(none)'}`, + ` ignoreValues: ${formatValues(config.ignoreValues)}`, + ` designSystem: ${config.designSystem?.enabled === false ? 'disabled' : 'enabled'}`, + ].join('\n'); +} + +function list(cwd) { + const merged = readDetectionConfig(cwd); + const shared = readRawDetectionConfig(cwd); + const local = readRawDetectionConfig(cwd, { local: true }); + return [ + 'Impeccable detector ignores', + ` shared file: ${path.relative(cwd, getConfigPath(cwd)) || getConfigPath(cwd)}`, + ` local file: ${path.relative(cwd, getLocalConfigPath(cwd)) || getLocalConfigPath(cwd)}`, + '', + formatConfig('Merged', merged), + '', + formatConfig('Shared', shared), + '', + formatConfig('Local', local), + ].join('\n'); +} + +function readScopeConfig(cwd, local) { + return readRawDetectionConfig(cwd, { local }); +} + +function writeScopeConfig(cwd, config, local) { + return writeDetectionConfig(cwd, config, { local }); +} + +function parseRuleArgs(args) { + const positionals = []; + let allValues = false; + + for (let i = 0; i < args.length; i++) { + const arg = String(args[i] || ''); + if (arg === '--all-values') { + allValues = true; + } else if (arg === '--reason') { + while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++; + } else if (arg.startsWith('--reason=')) { + // Accepted for symmetry with add-value; ignoreRules stores ids only. + } else if (arg.startsWith('--')) { + throw new Error(`Unknown add-rule flag: ${arg}`); + } else { + positionals.push(arg); + } + } + + return { + rule: String(positionals[0] || '').trim().toLowerCase(), + allValues, + }; +} + +function addRule(cwd, args) { + const { local, rest } = parseScope(args); + const { rule, allValues } = parseRuleArgs(rest); + if (!rule) throw new Error('Pass a rule id, e.g. impeccable ignores add-rule side-tab'); + if (rule === 'overused-font' && !allValues) { + throw new Error('overused-font is value-specific by default. Use add-value overused-font , or add-rule overused-font --all-values for broad suppression.'); + } + const config = readScopeConfig(cwd, local); + if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule); + const target = writeScopeConfig(cwd, config, local); + return `Added ${rule} to ${local ? 'local' : 'shared'} detector ignoreRules (${path.relative(cwd, target) || target}).`; +} + +function addFile(cwd, args) { + const { local, rest } = parseScope(args); + const glob = String(rest[0] || '').trim(); + if (!glob) throw new Error('Pass a glob, e.g. impeccable ignores add-file "src/legacy/**"'); + const config = readScopeConfig(cwd, local); + if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob); + const target = writeScopeConfig(cwd, config, local); + return `Added ${glob} to ${local ? 'local' : 'shared'} detector ignoreFiles (${path.relative(cwd, target) || target}).`; +} + +function addValue(cwd, args) { + const { local, rest } = parseScope(args); + const parsed = parseValueArgs(rest); + const config = readScopeConfig(cwd, local); + const key = ignoreValueKey(parsed); + const existing = config.ignoreValues.find((entry) => ignoreValueKey(entry) === key); + if (existing) { + if (parsed.reason) existing.reason = parsed.reason; + if (parsed.files.length) existing.files = parsed.files; + } else { + const entry = { + rule: parsed.rule, + value: parsed.value, + createdAt: new Date().toISOString(), + }; + if (parsed.files.length) entry.files = parsed.files; + if (parsed.reason) entry.reason = parsed.reason; + config.ignoreValues.push(entry); + } + const target = writeScopeConfig(cwd, config, local); + return `Added ${parsed.rule}=${parsed.value} to ${local ? 'local' : 'shared'} detector ignoreValues (${path.relative(cwd, target) || target}).`; +} + +function removeFromScopes(cwd, args, remover) { + const { local, all, rest } = parseScope(args, { allowAll: true }); + const scopes = all ? [false, true] : [local]; + const removed = []; + for (const isLocal of scopes) { + const config = readScopeConfig(cwd, isLocal); + const count = remover(config, rest); + if (count > 0) { + const target = writeScopeConfig(cwd, config, isLocal); + removed.push(`${count} from ${isLocal ? 'local' : 'shared'} (${path.relative(cwd, target) || target})`); + } + } + return removed.length ? `Removed ${removed.join(', ')}.` : 'No matching detector ignore found.'; +} + +function removeRule(cwd, args) { + return removeFromScopes(cwd, args, (config, rest) => { + const rule = String(rest[0] || '').trim().toLowerCase(); + if (!rule) throw new Error('Pass a rule id, e.g. impeccable ignores remove-rule side-tab'); + const before = config.ignoreRules.length; + config.ignoreRules = config.ignoreRules.filter((entry) => entry !== rule); + return before - config.ignoreRules.length; + }); +} + +function removeFile(cwd, args) { + return removeFromScopes(cwd, args, (config, rest) => { + const glob = String(rest[0] || '').trim(); + if (!glob) throw new Error('Pass a glob, e.g. impeccable ignores remove-file "src/legacy/**"'); + const before = config.ignoreFiles.length; + config.ignoreFiles = config.ignoreFiles.filter((entry) => entry !== glob); + return before - config.ignoreFiles.length; + }); +} + +function removeValue(cwd, args) { + return removeFromScopes(cwd, args, (config, rest) => { + const parsed = parseValueArgs(rest, { allowUnscopedWildcard: true }); + const key = ignoreValueKey(parsed); + const before = config.ignoreValues.length; + config.ignoreValues = config.ignoreValues.filter((entry) => ignoreValueKey(entry) !== key); + return before - config.ignoreValues.length; + }); +} + +function clear(cwd, args) { + const { local, all, rest } = parseScope(args, { allowAll: true }); + if (rest.length > 0) throw new Error('clear does not take positional arguments'); + const scopes = all ? [false, true] : [local]; + for (const isLocal of scopes) { + const config = readScopeConfig(cwd, isLocal); + config.ignoreRules = []; + config.ignoreFiles = []; + config.ignoreValues = []; + writeScopeConfig(cwd, config, isLocal); + } + return `Cleared detector ignores in ${all ? 'shared and local config' : local ? 'local config' : 'shared config'}.`; +} + +function ignoreValueKey(entry) { + const files = Array.isArray(entry.files) && entry.files.length ? entry.files.join('\x1f') : ''; + return `${String(entry.rule || '').trim().toLowerCase()}\0${normalizeIgnoreValue(entry.value)}\0${files}`; +} + +export async function run(args = [], opts = {}) { + const cwd = opts.cwd || process.cwd(); + const actionArg = args[0] || 'list'; + if (actionArg === '--help' || actionArg === '-h') { + printUsage(); + return; + } + const action = ACTION_ALIASES.get(String(actionArg).toLowerCase()); + if (!action) { + throw new Error(`Unknown ignores action: ${actionArg}. Run "impeccable ignores --help".`); + } + const rest = args.slice(1); + let out; + switch (action) { + case 'list': out = list(cwd); break; + case 'add-rule': out = addRule(cwd, rest); break; + case 'add-file': out = addFile(cwd, rest); break; + case 'add-value': out = addValue(cwd, rest); break; + case 'remove-rule': out = removeRule(cwd, rest); break; + case 'remove-file': out = removeFile(cwd, rest); break; + case 'remove-value': out = removeValue(cwd, rest); break; + case 'clear': out = clear(cwd, rest); break; + } + if (out) console.log(out); +} diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 12aec29ce..fff811491 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1224,6 +1224,7 @@ if (IS_BROWSER) { category: ap ? ap.category : 'quality', severity: ap?.severity || 'warning', detail: f.detail || f.snippet, + ignoreValue: f.ignoreValue || f.value || '', name: ap ? ap.name : (f.type || f.id), description: ap ? ap.description : '', }; @@ -1260,10 +1261,203 @@ if (IS_BROWSER) { return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); } + const DESIGN_COLOR_TOLERANCE = 6; + const DESIGN_RADIUS_TOLERANCE_PX = 0.5; + const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); + + function normalizeBrowserFontName(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + function browserPrimaryFont(stack) { + if (!stack || /var\(/i.test(stack)) return ''; + return String(stack || '') + .split(',') + .map(normalizeBrowserFontName) + .find(font => font && !GENERIC_FONTS.has(font)) || ''; + } + + function browserDesignSystemConfig() { + const raw = window.__IMPECCABLE_CONFIG__?.designSystem; + if (!raw?.present) return null; + const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean)); + const allowedColors = (raw.allowedColors || []) + .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) + .map(color => ({ r: color.r, g: color.g, b: color.b })); + const allowedRadii = (raw.allowedRadii || []) + .map(Number) + .filter(px => Number.isFinite(px)); + return { + present: true, + hasFonts: raw.hasFonts === true && allowedFonts.size > 0, + allowedFonts, + hasColors: raw.hasColors === true && allowedColors.length > 0, + allowedColors, + hasRadii: raw.hasRadii === true && allowedRadii.length > 0, + allowedRadii, + hasPillRadius: raw.hasPillRadius === true, + }; + } + + function browserColorsClose(a, b) { + if (!a || !b) return false; + return Math.max( + Math.abs(a.r - b.r), + Math.abs(a.g - b.g), + Math.abs(a.b - b.b), + ) <= DESIGN_COLOR_TOLERANCE; + } + + function isBrowserDesignColorAllowed(raw, designSystem) { + if (!designSystem?.hasColors) return true; + const text = String(raw || '').trim().toLowerCase(); + if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; + if (text.includes('var(')) return true; + const parsed = parseAnyColor(text); + if (!parsed) return true; + if ((parsed.a ?? 1) <= 0.05) return true; + return designSystem.allowedColors.some(color => browserColorsClose(parsed, color)); + } + + function isBrowserTransparentCss(value) { + const text = String(value || '').trim().toLowerCase(); + if (!text || text === 'transparent') return true; + const parsed = parseAnyColor(text); + return parsed ? (parsed.a ?? 1) <= 0.05 : false; + } + + function isBrowserDesignRadiusAllowed(raw, designSystem) { + if (!designSystem?.hasRadii) return true; + const text = String(raw || '').trim().toLowerCase(); + if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; + if (text.includes('var(') || text.includes('%')) return true; + const px = resolveLengthPx(text, 16); + if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true; + if (designSystem.hasPillRadius && px >= 99) return true; + return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX); + } + + function browserRadiusTokens(value) { + return String(value || '') + .replace(/\s*\/\s*/g, ' ') + .split(/\s+/) + .map(token => token.trim()) + .filter(Boolean); + } + + function browserHasDirectText(el) { + return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0); + } + + function browserSampleText(el) { + const text = String(el.textContent || '').replace(/\s+/g, ' ').trim(); + return text ? ` "${text.slice(0, 40)}"` : ''; + } + + function shouldSkipDesignElement(el) { + const tag = el.tagName?.toLowerCase?.() || ''; + return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el); + } + + function checkElementDesignSystemDOM(el, designSystem, seen) { + if (!designSystem?.present || shouldSkipDesignElement(el)) return []; + const findings = []; + const tag = el.tagName?.toLowerCase?.() || 'unknown'; + const style = getComputedStyle(el); + + if (designSystem.hasFonts && browserHasDirectText(el)) { + const font = browserPrimaryFont(style.fontFamily || ''); + if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) { + seen.fonts.add(font); + findings.push({ + type: 'design-system-font', + detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`, + ignoreValue: font, + }); + } + } + + if (designSystem.hasColors) { + const colorChecks = []; + if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]); + if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]); + for (const side of ['Top', 'Right', 'Bottom', 'Left']) { + if ((parseFloat(style[`border${side}Width`]) || 0) > 0) { + colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]); + } + } + if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]); + + for (const [kind, raw] of colorChecks) { + const label = String(raw || '').trim().replace(/\s+/g, ' '); + if (isBrowserDesignColorAllowed(label, designSystem)) continue; + const key = `${kind}:${label}`; + if (seen.colors.has(key)) continue; + seen.colors.add(key); + findings.push({ + type: 'design-system-color', + detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`, + ignoreValue: label, + }); + } + } + + if (designSystem.hasRadii) { + for (const token of browserRadiusTokens(style.borderRadius || '')) { + if (isBrowserDesignRadiusAllowed(token, designSystem)) continue; + if (seen.radii.has(token)) continue; + seen.radii.add(token); + findings.push({ + type: 'design-system-radius', + detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`, + ignoreValue: token, + }); + } + } + + return findings; + } + + function decodeBrowserGoogleFamily(value) { + const family = String(value || '').split(':')[0].replace(/\+/g, ' '); + try { + return decodeURIComponent(family); + } catch { + return family; + } + } + + function checkBrowserDesignSystemSources(designSystem, seen) { + if (!designSystem?.hasFonts) return []; + const findings = []; + for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) { + const href = link.getAttribute('href') || ''; + for (const match of href.matchAll(/[?&]family=([^&]+)/g)) { + const display = decodeBrowserGoogleFamily(match[1]); + const font = normalizeBrowserFontName(display); + if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue; + seen.fonts.add(font); + findings.push({ + type: 'design-system-font', + detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`, + ignoreValue: display, + }); + } + } + return findings; + } + function collectBrowserFindings() { const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); + const designSystem = browserDesignSystemConfig(); + const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() }; // Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a // real browser env (detector page, live overlay, extension) running every // check is free, so we always surface them; the gating is purely a CLI @@ -1294,6 +1488,7 @@ if (IS_BROWSER) { ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementDesignSystemDOM(el, designSystem, designSeen), ].filter(f => _ruleOk(f.type)); addBrowserFindings(groupMap, el, findings); @@ -1310,6 +1505,13 @@ if (IS_BROWSER) { const pageLevelFindings = []; + const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen) + .filter(f => _ruleOk(f.type)); + if (designSourceFindings.length > 0) { + pageLevelFindings.push(...designSourceFindings); + addBrowserFindings(groupMap, document.body, designSourceFindings); + } + const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); if (typoFindings.length > 0) { pageLevelFindings.push(...typoFindings); diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs index da2fe80bb..84e7a826e 100644 --- a/cli/engine/cli/main.mjs +++ b/cli/engine/cli/main.mjs @@ -1,9 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; +import { loadDesignSystemForCwd } from '../design-system.mjs'; import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs'; import { detectHtml } from '../engines/static-html/detect-html.mjs'; import { detectText } from '../engines/regex/detect-text.mjs'; +import { + filterDetectionFindings, + readDetectionConfig, + shouldIgnoreDetectionFile, +} from '../../lib/impeccable-config.mjs'; import { HTML_EXTENSIONS, buildImportGraph, @@ -79,10 +85,17 @@ function printUsage() { Scan files or URLs for UI anti-patterns and design quality issues. Options: - --json Output results as JSON - --gpt Also report GPT-specific provider tells (off by default) - --gemini Also report Gemini-specific provider tells (off by default) - --help Show this help message + --json Output results as JSON + --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 + --no-design-system Do not load local DESIGN.md / .impeccable/design.json context + --help Show this help message + +Project config: + Respects .impeccable/config.json and .impeccable/config.local.json detector + settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, + and detector.designSystem.enabled. Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) @@ -93,7 +106,8 @@ Examples: impeccable detect src/ impeccable detect index.html impeccable detect https://example.com - impeccable detect --json .`); + impeccable detect --json . + impeccable detect --no-config src/`); } async function detectCli() { @@ -114,10 +128,16 @@ async function detectCli() { 'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n', ); } + const configEnabled = !args.includes('--no-config'); + const detectionConfig = configEnabled + ? readDetectionConfig(process.cwd()) + : { ignoreRules: [], ignoreFiles: [], ignoreValues: [] }; const providers = []; if (args.includes('--gpt')) providers.push('gpt'); if (args.includes('--gemini')) providers.push('gemini'); - const scanOptions = { providers }; + const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; + const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; + const scanOptions = designSystem ? { providers, designSystem } : { providers }; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } @@ -175,7 +195,8 @@ async function detectCli() { } } - const files = walkDir(resolved); + const files = walkDir(resolved) + .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); 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) @@ -219,6 +240,7 @@ async function detectCli() { allFindings.push(...fileFindings); } } else if (stat.isFile()) { + if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; const ext = path.extname(resolved).toLowerCase(); if (HTML_EXTENSIONS.has(ext)) { allFindings.push(...await detectHtml(resolved, scanOptions)); @@ -232,6 +254,8 @@ async function detectCli() { } } + allFindings = filterDetectionFindings(allFindings, detectionConfig); + if (allFindings.length > 0) { if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n'); diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs new file mode 100644 index 000000000..352148d86 --- /dev/null +++ b/cli/engine/design-system.mjs @@ -0,0 +1,750 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { finding } from './findings.mjs'; +import { GENERIC_FONTS } from './shared/constants.mjs'; +import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs'; + +const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; +const COLOR_CHANNEL_TOLERANCE = 6; +const RADIUS_TOLERANCE_PX = 0.5; + +const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi; +const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi; +const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g; +const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi; +const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi; +const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g; +const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); + +function firstExisting(dir, names) { + for (const name of names) { + const abs = path.join(dir, name); + if (fs.existsSync(abs)) return abs; + } + return null; +} + +function resolveDesignMdPath(cwd = process.cwd()) { + const root = firstExisting(cwd, DESIGN_NAMES); + if (root) return { path: root, contextDir: cwd }; + + for (const rel of FALLBACK_DIRS) { + const dir = path.resolve(cwd, rel); + const found = firstExisting(dir, DESIGN_NAMES); + if (found) return { path: found, contextDir: dir }; + } + + return null; +} + +function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + path.join(cwd, '.impeccable', 'design.json'), + path.join(cwd, 'DESIGN.json'), + path.join(contextDir, 'DESIGN.json'), + ]; + return candidates.find((candidate, index) => + candidates.indexOf(candidate) === index && fs.existsSync(candidate) + ) || null; +} + +function parseFrontmatter(md) { + const lines = String(md || '').split(/\r?\n/); + if (lines[0]?.trim() !== '---') return null; + let end = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { end = i; break; } + } + if (end === -1) return null; + try { + return parseYamlSubset(lines.slice(1, end).join('\n')); + } catch { + return null; + } +} + +function parseYamlSubset(yaml) { + const root = {}; + const stack = [{ indent: -1, obj: root }]; + + for (const raw of String(yaml || '').split(/\r?\n/)) { + if (!raw.trim() || /^\s*#/.test(raw)) continue; + const indent = raw.match(/^\s*/)[0].length; + const content = raw.slice(indent); + const colonIdx = findTopLevelColon(content); + if (colonIdx === -1) continue; + + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop(); + + const key = unquoteYamlKey(content.slice(0, colonIdx).trim()); + const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim()); + const parent = stack[stack.length - 1].obj; + + if (rest === '') { + const obj = {}; + parent[key] = obj; + stack.push({ indent, obj }); + } else { + parent[key] = parseScalar(rest); + } + } + + return root; +} + +function findTopLevelColon(s) { + let inQuote = null; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (inQuote) { + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === ':') { + return i; + } + } + return -1; +} + +function unquoteYamlKey(key) { + if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { + return key.slice(1, -1); + } + return key; +} + +function stripInlineYamlComment(s) { + let inQuote = null; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (inQuote) { + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) { + return s.slice(0, i).trimEnd(); + } + } + return s; +} + +function parseScalar(raw) { + const s = raw.trim(); + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + if (s === 'true') return true; + if (s === 'false') return false; + if (s === 'null' || s === '~') return null; + if (/^-?\d+$/.test(s)) return Number(s); + if (/^-?\d*\.\d+$/.test(s)) return Number(s); + return s; +} + +function safeReadJson(filePath) { + if (!filePath) return null; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + +function normalizeFontName(value) { + return String(value || '') + .trim() + .replace(/\s*!important\s*$/i, '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); +} + +function splitFontStack(stack) { + return String(stack || '') + .replace(/\s*!important\s*$/i, '') + .split(',') + .map(normalizeFontName) + .filter(Boolean); +} + +function primaryFont(stack) { + if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return ''; + return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || ''; +} + +function isLiteralFontStack(stack) { + const text = String(stack || ''); + return !/[$`{}]|\s\+\s|\|\|/.test(text); +} + +function cssColorLabel(raw) { + return String(raw || '').trim().replace(/\s+/g, ' '); +} + +function colorKey(color) { + if (!color) return ''; + return `${color.r},${color.g},${color.b}`; +} + +function colorsClose(a, b) { + if (!a || !b) return false; + return Math.max( + Math.abs(a.r - b.r), + Math.abs(a.g - b.g), + Math.abs(a.b - b.b), + ) <= COLOR_CHANNEL_TOLERANCE; +} + +function hslToRgb(H, S, L, alpha = 1) { + const h = (((H % 360) + 360) % 360) / 360; + const s = Math.max(0, Math.min(1, S)); + const l = Math.max(0, Math.min(1, L)); + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + return { + r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255), + g: Math.round(hue2rgb(p, q, h) * 255), + b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255), + a: alpha, + }; +} + +function parseDesignColor(value) { + const text = String(value || '').trim(); + const parsed = parseAnyColor(text); + if (parsed) return parsed; + const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i); + if (hsl) { + return hslToRgb( + parseFloat(hsl[1]), + parseFloat(hsl[2]) / 100, + parseFloat(hsl[3]) / 100, + hsl[4] !== undefined ? parseFloat(hsl[4]) : 1, + ); + } + return null; +} + +function addDesignColor(out, value, label) { + const parsed = parseDesignColor(value); + if (!parsed) return; + const key = colorKey(parsed); + if (!out.allowedColorKeys.has(key)) { + out.allowedColorKeys.set(key, { color: parsed, labels: [] }); + } + out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value)); +} + +function addColorObject(out, colors, prefix = 'colors') { + if (!colors || typeof colors !== 'object') return; + for (const [name, value] of Object.entries(colors)) { + if (typeof value === 'string') { + addDesignColor(out, value, `${prefix}.${name}`); + } + } +} + +function addSidecarColors(out, sidecar) { + const colorMeta = sidecar?.extensions?.colorMeta; + if (!colorMeta || typeof colorMeta !== 'object') return; + + for (const [name, meta] of Object.entries(colorMeta)) { + if (!meta || typeof meta !== 'object') continue; + if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`); + if (Array.isArray(meta.tonalRamp)) { + for (const [index, value] of meta.tonalRamp.entries()) { + if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`); + } + } + } +} + +function addTypographyFonts(out, typography) { + if (!typography || typeof typography !== 'object') return; + for (const role of Object.values(typography)) { + if (!role || typeof role !== 'object') continue; + if (typeof role.fontFamily !== 'string') continue; + for (const font of splitFontStack(role.fontFamily)) { + if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font); + } + } +} + +function addRoundedScale(out, rounded) { + if (!rounded || typeof rounded !== 'object') return; + for (const [rawName, value] of Object.entries(rounded)) { + const name = unquoteYamlKey(rawName).toLowerCase(); + addRoundedToken(out, name, value); + } +} + +function addRoundedToken(out, name, value) { + if (typeof value !== 'string' && typeof value !== 'number') return; + const raw = String(value).trim(); + if (!raw || /var\(/i.test(raw) || raw.includes('%')) return; + const px = resolveLengthPx(raw, 16); + if (px == null || !Number.isFinite(px)) return; + out.allowedRadii.push({ name, value: raw, px }); + if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true; +} + +function addSidecarRadii(out, sidecar) { + const roundedMeta = sidecar?.extensions?.roundedMeta; + if (!roundedMeta || typeof roundedMeta !== 'object') return; + + for (const [rawName, meta] of Object.entries(roundedMeta)) { + const name = unquoteYamlKey(rawName).toLowerCase(); + if (typeof meta === 'string' || typeof meta === 'number') { + addRoundedToken(out, `sidecar.${name}`, meta); + continue; + } + if (!meta || typeof meta !== 'object') continue; + for (const key of ['canonical', 'value']) { + if (typeof meta[key] === 'string' || typeof meta[key] === 'number') { + addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]); + } + } + for (const key of ['values', 'aliases']) { + if (!Array.isArray(meta[key])) continue; + for (const [index, value] of meta[key].entries()) { + addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value); + } + } + if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) { + out.hasPillRadius = true; + } + } +} + +function normalizeDesignSystem(input = {}) { + const frontmatter = input.frontmatter || {}; + const sidecar = input.sidecar || null; + const out = { + present: true, + sourcePath: input.sourcePath || null, + sidecarPath: input.sidecarPath || null, + mdNewerThanJson: input.mdNewerThanJson === true, + allowedFonts: new Set(), + allowedColorKeys: new Map(), + allowedRadii: [], + hasPillRadius: false, + }; + + addTypographyFonts(out, frontmatter.typography); + addColorObject(out, frontmatter.colors); + addSidecarColors(out, sidecar); + addRoundedScale(out, frontmatter.rounded); + addSidecarRadii(out, sidecar); + + out.hasFonts = out.allowedFonts.size > 0; + out.hasColors = out.allowedColorKeys.size > 0; + out.hasRadii = out.allowedRadii.length > 0; + return out; +} + +function loadDesignSystemForCwd(cwd = process.cwd()) { + const md = resolveDesignMdPath(cwd); + if (!md) return null; + + let frontmatter = null; + let mdStat = null; + try { + mdStat = fs.statSync(md.path); + frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8')); + } catch { + return null; + } + if (!frontmatter || typeof frontmatter !== 'object') return null; + + const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir); + const sidecar = safeReadJson(sidecarPath); + let sidecarStat = null; + try { + if (sidecarPath) sidecarStat = fs.statSync(sidecarPath); + } catch { + sidecarStat = null; + } + + return normalizeDesignSystem({ + frontmatter, + sidecar, + sourcePath: md.path, + sidecarPath, + mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000), + }); +} + +function isAllowedFont(font, designSystem) { + if (!font || GENERIC_FONTS.has(font)) return true; + if (!designSystem?.hasFonts) return true; + return designSystem.allowedFonts.has(font); +} + +function isAllowedColorRaw(raw, designSystem) { + if (!designSystem?.hasColors) return true; + const text = String(raw || '').trim().toLowerCase(); + if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; + if (text.includes('var(')) return true; + const parsed = parseDesignColor(text); + if (!parsed) return true; + if ((parsed.a ?? 1) <= 0.05) return true; + for (const entry of designSystem.allowedColorKeys.values()) { + if (colorsClose(parsed, entry.color)) return true; + } + return false; +} + +function isAllowedRadiusRaw(raw, designSystem) { + if (!designSystem?.hasRadii) return true; + const text = String(raw || '').trim().toLowerCase(); + if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; + if (text.includes('var(') || text.includes('%')) return true; + const px = resolveLengthPx(text, 16); + if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true; + if (designSystem.hasPillRadius && px >= 99) return true; + return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX); +} + +function lineLooksCommented(line) { + const trimmed = String(line || '').trim(); + return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('
-

Iterate where you can see it.

+

Iterate where you can see it

Use Live Mode when an issue is easier to point at than describe. Pick an element, generate variants, accept one into source.

@@ -200,7 +239,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[

-

Full session path.

+

Full session path

Plan, build, review, refine.

@@ -229,7 +268,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
-

Useful command pairs.

+

Useful command pairs

Use these when one pass is not enough.

@@ -250,7 +289,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
-

Full command reference.

+

Full command reference

diff --git a/site/pages/index.astro b/site/pages/index.astro index 90011ab4f..4c6d9d078 100644 --- a/site/pages/index.astro +++ b/site/pages/index.astro @@ -521,7 +521,7 @@ import '../styles/testimonials.css';
06

Block slop before it ships.

-

A detector you can wire into PR checks. 41 deterministic rules, no LLM, exit codes the build can read.

+

A detector you can wire into PR checks. 44 deterministic rules, no LLM, exit codes the build can read.

Easiest

diff --git a/site/pages/tutorials/[...slug].astro b/site/pages/tutorials/[...slug].astro index 101eec916..b8ed77d4c 100644 --- a/site/pages/tutorials/[...slug].astro +++ b/site/pages/tutorials/[...slug].astro @@ -30,7 +30,6 @@ const { Content } = await render(entry);
- Tutorial

{entry.data.title}

{entry.data.tagline &&

{entry.data.tagline}

}
diff --git a/site/public/llms.txt b/site/public/llms.txt index 995e0325b..fb85b4954 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -9,7 +9,7 @@ Use the website pages below as the current public documentation. Use the GitHub - [Home](https://impeccable.style/): Product overview, install options, supported AI coding harnesses, and download entry points. - [Designing with Impeccable](https://impeccable.style/designing): The end-to-end workflow from setup and brief to iteration, polish, and maintenance. - [Documentation](https://impeccable.style/docs): Command reference index for `/impeccable` and its commands. -- [Getting started](https://impeccable.style/tutorials/getting-started): Install Impeccable, run `/impeccable init`, create project context, and start with a polish pass. +- [Getting started](https://impeccable.style/tutorials/getting-started): Install Impeccable, run `/impeccable init`, create design context, and start with a polish pass. - [Live Mode](https://impeccable.style/live-mode): Browser-based UI iteration with element picking, annotations, generated variants, and source writeback. - [Slop](https://impeccable.style/slop): Catalog of AI-generated UI anti-patterns and the detection overlay behind the CLI, extension, and critique workflow. @@ -17,13 +17,13 @@ Use the website pages below as the current public documentation. Use the GitHub - [Tutorials index](https://impeccable.style/tutorials): Step-by-step guides for installation, live iteration, and visual critique. - [Iterate on UI with Live Mode](https://impeccable.style/tutorials/iterate-live): Use `/impeccable live` against a running dev server to generate and accept UI variants. -- [Brand vs product](https://impeccable.style/tutorials/brand-vs-product): Explain the setup choice between marketing surfaces and app surfaces. +- [Design Context](https://impeccable.style/docs/context): Explain PRODUCT.md, DESIGN.md, register, and the design sidecar. - [Critique with the visual overlay](https://impeccable.style/tutorials/critique-with-overlay): Combine `/impeccable critique`, deterministic detection, and the browser overlay. ## Command Reference - [impeccable](https://impeccable.style/docs/impeccable): Main command for recommendations, freeform design work, and loading the full design guide. -- [init](https://impeccable.style/docs/init): Set up project context with `PRODUCT.md`, optional `DESIGN.md`, Live Mode configuration, and next-command recommendations. +- [init](https://impeccable.style/docs/init): Set up design context with `PRODUCT.md`, optional `DESIGN.md`, Live Mode configuration, and next-command recommendations. - [craft](https://impeccable.style/docs/craft): Shape, build, and visually iterate a new feature end to end. - [shape](https://impeccable.style/docs/shape): Run a discovery interview and produce a design brief before code. - [live](https://impeccable.style/docs/live): Select browser elements, generate UI variants, and write accepted changes back to source. diff --git a/site/scripts/effects/liquid-canvas.js b/site/scripts/effects/liquid-canvas.js index 896f27c29..274f3b9d9 100644 --- a/site/scripts/effects/liquid-canvas.js +++ b/site/scripts/effects/liquid-canvas.js @@ -93,7 +93,7 @@ export function initHeroEffect() { // Draw grid lines ctx.beginPath(); - ctx.strokeStyle = "rgba(100, 40, 50, 0.06)"; // Very subtle base + ctx.strokeStyle = "rgba(255, 255, 255, 0.06)"; // Very subtle base ctx.lineWidth = 1; const cols = Math.ceil(width / gap) + 1; @@ -162,4 +162,3 @@ export function initHeroEffect() { } - diff --git a/site/styles/design-system.css b/site/styles/design-system.css index 1968ecc97..f46b4c966 100644 --- a/site/styles/design-system.css +++ b/site/styles/design-system.css @@ -826,7 +826,7 @@ .ks-logo-tile-links a { color: var(--ks-text); text-decoration: none; - border-bottom: 1px solid oklch(40% 0.015 95); + border-bottom: 1px solid var(--ks-rule); padding-bottom: 1px; transition: color 160ms ease, border-color 160ms ease; } diff --git a/site/styles/designing-kinpaku.css b/site/styles/designing-kinpaku.css index b0e40e461..46297ec5d 100644 --- a/site/styles/designing-kinpaku.css +++ b/site/styles/designing-kinpaku.css @@ -878,7 +878,7 @@ width: 9px; height: 9px; border-radius: 50%; - background: oklch(40% 0.02 82); + background: var(--ks-rule); } .designing-kinpaku .docs-viz-live-url { margin-left: 8px; diff --git a/site/styles/divider-patterns.css b/site/styles/divider-patterns.css index 72abf8cfb..9f023e06d 100644 --- a/site/styles/divider-patterns.css +++ b/site/styles/divider-patterns.css @@ -22,7 +22,7 @@ the gold; plinth is a low-chroma warm dark that sits one step above the lacquer bg and reads as inscribed into the surface. */ .ksd-divider--patina { color: var(--ks-patina); } -.ksd-divider--plinth { color: oklch(0.42 0.014 82); } +.ksd-divider--plinth { color: oklch(42% 0 0); } /* Texture-fill modifiers. The pattern's geometry becomes a mask; the brand accent texture becomes the visible fill. Filters match the .ks-accent-band diff --git a/site/styles/docs-kinpaku.css b/site/styles/docs-kinpaku.css index 9d138d457..6c55b47ef 100644 --- a/site/styles/docs-kinpaku.css +++ b/site/styles/docs-kinpaku.css @@ -221,15 +221,15 @@ html.light .docs-kinpaku { align-items: center; justify-content: space-between; width: 100%; - padding: 14px 0; + padding: 12px 0; background: transparent; border: 0; - color: var(--ks-champagne); + color: var(--ks-text-muted); font-family: var(--ks-font); - font-size: 0.76rem; - font-weight: 650; - letter-spacing: 0.14em; - text-transform: uppercase; + font-size: 0.92rem; + font-weight: 500; + letter-spacing: 0; + text-transform: none; cursor: pointer; } @@ -321,6 +321,7 @@ html.light .docs-kinpaku { /* Shared section scaffolding for the index sections ---------------- */ .docs-kinpaku .docs-chooser-section, +.docs-kinpaku .docs-manual-section, .docs-kinpaku .docs-live, .docs-kinpaku .docs-flow-section, .docs-kinpaku .docs-chains-section, @@ -332,19 +333,8 @@ html.light .docs-kinpaku { margin-bottom: 0; } -.docs-kinpaku .docs-live-eyebrow, -.docs-kinpaku .docs-flow-eyebrow, -.docs-kinpaku .docs-chains-eyebrow, -.docs-kinpaku .docs-reference-eyebrow { - font-family: var(--ks-mono); - font-size: 0.7rem; - letter-spacing: 0.24em; - text-transform: uppercase; - color: var(--ks-kinpaku); - margin: 0 0 14px; -} - .docs-kinpaku .docs-chooser-title, +.docs-kinpaku .docs-manual-title, .docs-kinpaku .docs-live-title, .docs-kinpaku .docs-flow-title, .docs-kinpaku .docs-chains-title, @@ -361,6 +351,7 @@ html.light .docs-kinpaku { } .docs-kinpaku .docs-live-lede, +.docs-kinpaku .docs-manual-sub, .docs-kinpaku .docs-flow-sub, .docs-kinpaku .docs-chains-sub { font-size: 1rem; @@ -378,23 +369,6 @@ html.light .docs-kinpaku { gap: clamp(12px, 1.8vw, 18px); } -.docs-kinpaku .docs-start-rail-divider { - color: var(--docs-accent); - opacity: 0.86; -} - -html.light .docs-kinpaku .docs-start-rail-divider { - opacity: 0.58; -} - -.docs-kinpaku .docs-start-rail-divider .ksd-divider { - color: currentColor; -} - -.docs-kinpaku .docs-start-rail-divider--top .ksd-divider { - transform: scaleY(-1); -} - .docs-kinpaku .docs-start-rail-steps { list-style: none; margin: 0; @@ -1034,21 +1008,8 @@ html.light .docs-kinpaku .docs-start-rail-divider { margin: 0 0 16px; } -/* Small `/impeccable` prefix above the command name — reads like a - kinpaku eyebrow so the command verb (the h1) is the real subject. - This replaces the earlier prefix-as-inline-text approach where - `/impeccable` was at display scale and competed with the verb. */ -.docs-kinpaku .sub-page-prefix { - display: block; - font-family: var(--ks-mono); - font-size: 0.86rem; - letter-spacing: 0.02em; - color: var(--ks-text-muted); - margin: 0 0 8px; -} - /* Command name (h1) — display scale, kinpaku gold so it carries the - page identity. The prefix above provides the `/impeccable …` context. */ + page identity. */ .docs-kinpaku .sub-page-title { font-family: var(--ks-font-display); font-weight: var(--ks-type-display-weight); @@ -1220,8 +1181,6 @@ html.light .docs-kinpaku .docs-start-rail-divider { font-weight: var(--ks-type-headline-weight); line-height: var(--ks-type-headline-line); letter-spacing: -0.005em; - padding-bottom: 14px; - border-bottom: 1px solid var(--ks-rule); text-wrap: balance; } @@ -1299,21 +1258,27 @@ html.light .docs-kinpaku .docs-start-rail-divider { color: var(--ks-code-fg); background: var(--ks-code-bg); border: 0; - padding: var(--ks-code-pad); + padding: 0.14em 0.36em; border-radius: var(--ks-code-radius); } +.docs-kinpaku .prose :not(pre) > code { + overflow-wrap: anywhere; +} + +html.light .docs-kinpaku .prose code { + background: oklch(25% 0.02 95 / 0.08); +} + /* Inline code that is itself a link — get the kinpaku cue back, since the link semantic is the meaningful one. */ .docs-kinpaku .prose a code { color: var(--ks-code-cmd); } -/* Fenced code blocks — deep lacquer terminal. Shiki injects inline - `background-color` and `color` on `pre.astro-code` (default theme is - `github-dark`, which reads cool-gray against the warm kinpaku ground), - so we override with !important to bake the theme into the kinpaku - palette. Syntax token spans inside keep their own colors. */ +/* Fenced code blocks — deep lacquer terminal. Astro/Shiki emits dual-theme + CSS variables from site/lib/impeccable-shiki-theme.mjs; the wrapper keeps + the surface in the kinpaku token system while token spans swap by theme. */ .docs-kinpaku .prose pre, .docs-kinpaku .prose .code-block, .docs-kinpaku .prose pre.astro-code { @@ -1330,6 +1295,24 @@ html.light .docs-kinpaku .docs-start-rail-divider { max-width: 100%; } +.docs-kinpaku .prose pre.astro-code span { + color: var(--shiki-dark, var(--ks-code-block-fg)); +} + +html.light .docs-kinpaku .prose pre.astro-code span { + color: var(--shiki-light, var(--ks-code-block-fg)); +} + +.docs-kinpaku .prose > * + pre.astro-code, +.docs-kinpaku .prose > * + pre, +.docs-kinpaku .prose > * + .code-block-wrap { + margin-top: 1.25em; +} + +.docs-kinpaku .prose :is(p, ul, ol) + :is(pre.astro-code, pre, .code-block-wrap) { + margin-top: 1.25em; +} + .docs-kinpaku .prose pre code, .docs-kinpaku .prose .code-block code { background: transparent; @@ -1340,6 +1323,11 @@ html.light .docs-kinpaku .docs-start-rail-divider { border-radius: 0; } +html.light .docs-kinpaku .prose pre code, +html.light .docs-kinpaku .prose .code-block code { + background: transparent; +} + .docs-kinpaku .prose .code-block-wrap { position: relative; margin: 0; @@ -1394,11 +1382,18 @@ html.light .docs-kinpaku .docs-start-rail-divider { .docs-kinpaku .prose blockquote { margin: 0; padding: 6px 0 6px 22px; + border-block: 0; border-left: 2px solid var(--ks-kinpaku); color: var(--ks-text); font-style: italic; } +.docs-kinpaku .prose .docs-context-note { + padding: 4px 0 4px 20px; + border-left: 2px solid var(--ks-kinpaku); + color: var(--ks-text-muted); +} + .docs-kinpaku .prose hr { border: 0; height: 1px; @@ -1430,6 +1425,164 @@ html.light .docs-kinpaku .docs-start-rail-divider { border-bottom-color: var(--ks-kinpaku); } +@media (max-width: 760px) { + .docs-kinpaku .prose table { + display: block; + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .docs-kinpaku .prose th, + .docs-kinpaku .prose td { + min-width: 11rem; + } +} + +/* Design Context guide ------------------------------------------------- + + The context page needs a quick mental model before it becomes reference + prose. Keep these modules small, sharp, and readable inside the normal + documentation measure. +*/ + +.docs-kinpaku .prose .docs-context-flow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + background: var(--ks-rule); + border: 1px solid var(--ks-rule); + border-radius: 6px; + overflow: hidden; +} + +.docs-kinpaku .prose .docs-context-flow-source, +.docs-kinpaku .prose .docs-context-flow-output { + display: grid; + gap: 8px; + min-width: 0; + padding: 18px; + background: var(--ks-lacquer-raised); +} + +.docs-kinpaku .prose .docs-context-flow-source--generated { + background: var(--ks-lacquer-raised); +} + +.docs-kinpaku .prose .docs-context-flow-output { + grid-column: 1 / -1; + grid-template-columns: minmax(0, 0.74fr) minmax(0, 1.26fr); + column-gap: 20px; + align-items: center; + background: var(--ks-lacquer); +} + +.docs-kinpaku .prose .docs-context-flow-label { + font-family: var(--ks-mono); + font-size: 0.62rem; + line-height: 1.2; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--docs-accent); +} + +.docs-kinpaku .prose .docs-context-flow-source strong, +.docs-kinpaku .prose .docs-context-flow-output strong { + color: var(--ks-champagne); + font-family: var(--ks-mono); + font-size: 0.84rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.docs-kinpaku .prose .docs-context-flow-source span:last-child, +.docs-kinpaku .prose .docs-context-flow-output span:last-child { + color: var(--ks-text-muted); + font-size: 0.9rem; + line-height: 1.45; +} + +.docs-kinpaku .prose .docs-context-flow-output .docs-context-flow-label, +.docs-kinpaku .prose .docs-context-flow-output span:last-child { + grid-column: 1; +} + +.docs-kinpaku .prose .docs-context-flow-output strong { + grid-column: 2; + grid-row: 1 / 3; + align-self: center; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) { + border-top: 1px solid var(--ks-rule); + border-bottom: 1px solid var(--ks-rule); + padding: 0; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) + :is(.docs-context-details, .docs-prose-details) { + margin-top: 0; + border-top: 0; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 16px 0; + color: var(--ks-champagne); + font-weight: 600; + cursor: pointer; + list-style: none; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) summary::-webkit-details-marker { + display: none; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) summary::after { + content: "+"; + font-family: var(--ks-mono); + color: var(--docs-accent); + font-weight: 500; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details)[open] summary::after { + content: "-"; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) summary:focus-visible { + outline: 2px solid var(--ks-patina); + outline-offset: 4px; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) > div { + display: grid; + gap: 0.8em; + padding: 0 0 18px; + color: var(--ks-text); +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) > div > * { + margin: 0; +} + +.docs-kinpaku .prose :is(.docs-context-details, .docs-prose-details) ul { + padding-left: 1.4rem; +} + +@media (max-width: 760px) { + .docs-kinpaku .prose .docs-context-flow { + grid-template-columns: 1fr; + } + + .docs-kinpaku .prose .docs-context-flow-output { + grid-column: auto; + grid-template-columns: 1fr; + gap: 8px; + } +} + /* Two-column editorial hero (when the command has a registered demo). Demo on the left, h1 + lede on the right. Mirrors the old pre-Astro @@ -1501,7 +1654,7 @@ html.light .docs-kinpaku .docs-start-rail-divider { .docs-kinpaku .docs-command-demo .split-before { background: linear-gradient(90deg, oklch(10% 0.006 95 / 0.92), oklch(12% 0.012 60 / 0.72)), - radial-gradient(circle at 28% 25%, oklch(60% 0.05 80 / 0.18), transparent 11rem); + radial-gradient(circle at 28% 25%, oklch(78% 0.12 82 / 0.12), transparent 11rem); } .docs-kinpaku .docs-command-demo .split-after { diff --git a/site/styles/docs-visuals.css b/site/styles/docs-visuals.css index 413657e88..13c63681a 100644 --- a/site/styles/docs-visuals.css +++ b/site/styles/docs-visuals.css @@ -494,7 +494,7 @@ color: var(--color-accent); font-family: var(--font-mono); font-size: 10px; - border-radius: 11px; + border-radius: 10px; } .docs-viz-designmd-card { @@ -541,7 +541,7 @@ font-size: 9px; font-weight: 700; letter-spacing: 0.1em; - color: oklch(55% 0.22 25); + color: var(--ks-vermilion); flex-shrink: 0; } @@ -608,8 +608,10 @@ /* BRAND variant — editorial-magazine, serif display, drenched accent */ .docs-viz-register-frame--brand { - background: oklch(96% 0.02 30); - border-color: oklch(80% 0.08 30); + background: + linear-gradient(135deg, oklch(78% 0.12 82 / 0.1), transparent 54%), + var(--color-cream); + border-color: var(--color-accent-soft); padding: 28px 22px; } @@ -619,7 +621,7 @@ font-weight: 500; letter-spacing: 0.2em; text-transform: uppercase; - color: oklch(40% 0.12 30); + color: var(--color-accent); margin-bottom: 8px; } @@ -629,7 +631,7 @@ font-weight: 400; font-size: 28px; line-height: 1; - color: oklch(20% 0.1 30); + color: var(--color-ink); margin: 0 0 10px 0; } @@ -638,7 +640,7 @@ font-style: italic; font-size: 13px; line-height: 1.5; - color: oklch(35% 0.04 30); + color: var(--color-charcoal); margin: 0 0 14px 0; max-width: 26ch; } @@ -646,8 +648,8 @@ .docs-viz-register-frame--brand .docs-viz-reg-btn { align-self: flex-start; padding: 8px 18px; - background: oklch(20% 0.1 30); - color: oklch(96% 0.02 30); + background: var(--color-ink); + color: var(--color-paper); font-family: var(--font-mono); font-size: 10px; font-weight: 500; @@ -898,11 +900,11 @@ } .docs-viz-report-dim-fill--warn { - background: oklch(72% 0.15 70); + background: var(--ks-kinpaku-rich); } .docs-viz-report-dim-fill--fail { - background: oklch(60% 0.2 25); + background: var(--ks-vermilion); } .docs-viz-report-dim-score { @@ -929,10 +931,10 @@ border-radius: 3px; } -.docs-viz-report-sev--p0 { background: oklch(50% 0.22 25); } -.docs-viz-report-sev--p1 { background: oklch(58% 0.18 45); } -.docs-viz-report-sev--p2 { background: oklch(70% 0.13 80); } -.docs-viz-report-sev--p3 { background: oklch(55% 0.06 260); } +.docs-viz-report-sev--p0 { background: var(--ks-vermilion); } +.docs-viz-report-sev--p1 { background: oklch(52% 0.16 35); } +.docs-viz-report-sev--p2 { background: var(--ks-kinpaku-rich); } +.docs-viz-report-sev--p3 { background: var(--color-ash); } .docs-viz-report-sev-n { font-weight: 500; @@ -980,7 +982,7 @@ display: inline-flex; align-items: center; padding: 3px 10px; - background: oklch(60% 0.2 25); + background: var(--ks-vermilion); color: var(--color-paper); font-family: var(--font-mono); font-size: 11px; @@ -990,7 +992,7 @@ } .docs-viz-critique-verdict-value--pass { - background: oklch(52% 0.18 150); + background: oklch(45% 0.18 145); } .docs-viz-critique-cols { @@ -1083,9 +1085,9 @@ text-align: right; } -.docs-viz-critique-heur-score--good { color: oklch(45% 0.18 150); } -.docs-viz-critique-heur-score--ok { color: oklch(55% 0.15 70); } -.docs-viz-critique-heur-score--bad { color: oklch(55% 0.22 25); } +.docs-viz-critique-heur-score--good { color: oklch(45% 0.18 145); } +.docs-viz-critique-heur-score--ok { color: var(--ks-kinpaku-rich); } +.docs-viz-critique-heur-score--bad { color: var(--ks-vermilion); } /* ============================================ STEP ARTIFACTS — inline mocks in tutorials @@ -1235,7 +1237,7 @@ .docs-viz-variant--v1 { background: var(--color-cream); } .docs-viz-variant--v2 .docs-viz-variant-btn { background: var(--color-accent); } -.docs-viz-variant--v3 { background: oklch(96% 0.02 350); } +.docs-viz-variant--v3 { background: var(--color-cream); } /* Accept pill */ .docs-viz-accept-pill { @@ -1328,7 +1330,7 @@ margin: 0; max-width: 56ch; padding: 10px 14px; - border-left: 2px solid var(--color-accent); + box-shadow: inset 2px 0 0 var(--color-accent); background: color-mix(in oklab, var(--color-accent) 6%, transparent); border-radius: 0 4px 4px 0; } @@ -1387,7 +1389,7 @@ } .live-mode-start-copy.is-copied { - color: oklch(70% 0.18 145); + color: oklch(45% 0.18 145); } /* Demo section — wraps the animated homepage .live-demo block. @@ -2745,7 +2747,9 @@ } .designing-lane-mock--brand { - background: oklch(96% 0.02 30); + background: + linear-gradient(135deg, oklch(78% 0.12 82 / 0.1), transparent 54%), + var(--color-cream); } .designing-lane-mock--brand .designing-lane-mock-label { @@ -2753,14 +2757,14 @@ font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase; - color: oklch(40% 0.12 30); + color: var(--color-accent); } .designing-lane-mock--brand .designing-lane-mock-title { font-family: var(--font-display); font-style: italic; font-size: 22px; - color: oklch(20% 0.1 30); + color: var(--color-ink); line-height: 1.1; } @@ -2846,8 +2850,8 @@ justify-content: center; width: 20px; height: 20px; - background: oklch(95% 0.04 25); - color: oklch(55% 0.22 25); + background: oklch(58% 0.15 35 / 0.1); + color: var(--ks-vermilion); border-radius: 50%; font-family: var(--font-mono); font-size: 12px; diff --git a/site/styles/gallery.css b/site/styles/gallery.css index 9014df622..c12638889 100644 --- a/site/styles/gallery.css +++ b/site/styles/gallery.css @@ -175,7 +175,7 @@ .gallery-dot:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; - border-radius: 1px; + border-radius: 2px; } .gallery-dot.active { diff --git a/site/styles/home-kinpaku.css b/site/styles/home-kinpaku.css index ea63992db..4fe6d71af 100644 --- a/site/styles/home-kinpaku.css +++ b/site/styles/home-kinpaku.css @@ -53,7 +53,7 @@ --cat-harden-bg: linear-gradient(155deg, oklch(49% 0.08 188 / 0.16), transparent 58%), oklch(8% 0.01 95); --cat-harden-border: oklch(58% 0.085 185 / 0.74); --cat-harden-text: oklch(71% 0.082 184); - --cat-system-bg: linear-gradient(155deg, oklch(84% 0.035 82 / 0.08), transparent 58%), oklch(10% 0.007 95); + --cat-system-bg: linear-gradient(155deg, oklch(91% 0 0 / 0.08), transparent 58%), oklch(10% 0.007 95); --cat-system-border: oklch(58% 0.028 82 / 0.5); --cat-system-text: oklch(71% 0.028 82); @@ -1258,15 +1258,15 @@ --demo-panel: oklch(13% 0.008 95); --demo-panel-raised: oklch(17% 0.01 95); --demo-panel-soft: oklch(20% 0.012 95); - --demo-border: oklch(58% 0.052 82 / 0.34); - --demo-text: oklch(82% 0.026 82); - --demo-muted: oklch(61% 0.019 82); - --demo-dim: oklch(48% 0.016 82); - --demo-warning-panel: oklch(25% 0.046 72); - --demo-danger-panel: oklch(22% 0.045 35); - --demo-info-panel: oklch(22% 0.055 205); - --demo-purple: oklch(55% 0.16 286); - --demo-purple-soft: oklch(24% 0.06 286); + --demo-border: var(--ks-rule); + --demo-text: var(--ks-text); + --demo-muted: var(--ks-text-muted); + --demo-dim: var(--ks-text-faint); + --demo-warning-panel: oklch(22% 0.04 78); + --demo-danger-panel: oklch(22% 0.04 35); + --demo-info-panel: oklch(22% 0.04 188); + --demo-purple: var(--ks-kinpaku-rich); + --demo-purple-soft: oklch(22% 0.04 78); } .home-kinpaku :is(.spread-demo-area, .terminal-preview, .mobile-demo-area) .demo-split-comparison .split-container { @@ -1347,7 +1347,7 @@ .home-kinpaku :is(.spread-demo-area, .terminal-preview, .mobile-demo-area) .demo-split-comparison button[style*="#4F46E5"] { background: linear-gradient(180deg, var(--demo-purple), var(--demo-purple-soft)) !important; - color: oklch(92% 0.018 286) !important; + color: var(--ks-lacquer-deep) !important; } .home-kinpaku :is(.spread-demo-area, .terminal-preview, .mobile-demo-area) .demo-split-comparison [style*="border: 1px solid #ddd"], @@ -1379,7 +1379,7 @@ .home-kinpaku :is(.spread-demo-area, .terminal-preview, .mobile-demo-area) .demo-split-comparison [style*="color: #92400e"], .home-kinpaku :is(.spread-demo-area, .terminal-preview, .mobile-demo-area) .demo-split-comparison [style*="color: #854d0e"], .home-kinpaku :is(.spread-demo-area, .terminal-preview, .mobile-demo-area) .demo-split-comparison [style*="color: #78350f"] { - color: oklch(82% 0.08 75) !important; + color: var(--ks-kinpaku-pale) !important; } /* Primary CTA buttons that use background: var(--color-ink) — on home-kinpaku @@ -1694,7 +1694,7 @@ padding: 5px 13px; border-radius: 999px; border: 1px solid oklch(58% 0.15 35 / 0.42); - color: oklch(74% 0.14 35); + color: var(--ks-vermilion); background: oklch(58% 0.15 35 / 0.14); box-shadow: none; } @@ -1734,7 +1734,7 @@ } .home-kinpaku .why-ci-x { - color: oklch(68% 0.19 30); + color: var(--ks-vermilion); } .home-kinpaku .why-ci-summary { @@ -1973,8 +1973,8 @@ text-overflow: ellipsis; } .home-kinpaku .why-writes-line--del { - color: oklch(62% 0.13 30); - background: oklch(62% 0.13 30 / 0.06); + color: var(--ks-vermilion); + background: oklch(58% 0.15 35 / 0.06); } .home-kinpaku .why-writes-line--add { color: var(--ks-kinpaku); @@ -2040,7 +2040,7 @@ content: ""; position: absolute; left: 36px; - border: 1px solid oklch(84% 0.035 82 / 0.18); + border: 1px solid var(--ks-rule); border-radius: 4px; background: linear-gradient(135deg, oklch(78% 0.12 82 / 0.05), transparent 46%), @@ -2162,7 +2162,7 @@ overflow: hidden; border: 1px solid oklch(100% 0 0 / 0.08); background: - linear-gradient(180deg, oklch(96% 0.035 84 / 0.14), oklch(62% 0.08 78 / 0.22)), + linear-gradient(180deg, oklch(95% 0.04 84 / 0.14), oklch(61% 0.085 78 / 0.22)), url("/assets/neo-kinpaku/kinpaku-gold-leaf.png") center / cover, var(--c); box-shadow: inset 0 0 28px oklch(100% 0 0 / 0.06); @@ -2284,7 +2284,7 @@ .home-kinpaku .why-visual--v2 .v2-canvas-tool { color: var(--ks-muted); - border: 1px solid oklch(84% 0.035 82 / 0.16); + border: 1px solid var(--ks-rule); border-radius: 3px; background: linear-gradient(180deg, oklch(100% 0 0 / 0.025), transparent), @@ -2311,7 +2311,7 @@ max-width: none; height: 78px; overflow: hidden; - border: 1px solid oklch(84% 0.035 82 / 0.2); + border: 1px solid var(--ks-rule); border-radius: 4px; background: radial-gradient(circle at 82% 18%, oklch(78% 0.12 82 / 0.08), transparent 44%), @@ -2325,7 +2325,7 @@ .home-kinpaku .why-visual--v2 .v2-canvas-mock::after { content: ""; position: absolute; - border: 1px solid oklch(84% 0.035 82 / 0.16); + border: 1px solid var(--ks-rule); border-radius: 2px; } @@ -2343,7 +2343,7 @@ width: 72px; height: 40px; background: - repeating-linear-gradient(180deg, oklch(84% 0.035 82 / 0.32) 0 2px, transparent 2px 9px), + repeating-linear-gradient(180deg, oklch(91% 0 0 / 0.32) 0 2px, transparent 2px 9px), transparent; } diff --git a/site/styles/home-rebuild.css b/site/styles/home-rebuild.css index 25a0163f4..64d8f32a4 100644 --- a/site/styles/home-rebuild.css +++ b/site/styles/home-rebuild.css @@ -234,7 +234,7 @@ background: oklch(4% 0.004 95 / 0.72); backdrop-filter: blur(8px) saturate(1.4); -webkit-backdrop-filter: blur(8px) saturate(1.4); - border: 1px solid oklch(58% 0.065 82 / 0.32); + border: 1px solid var(--ks-rule); border-radius: 2px; font-size: 0.72rem; letter-spacing: 0.14em; @@ -244,7 +244,7 @@ .home-kinpaku .hero-rebuild-right .split-label-item[data-point="after"] { color: var(--ks-kinpaku); - border-color: oklch(78% 0.10 82 / 0.45); + border-color: oklch(78% 0.12 82 / 0.45); } @media (max-width: 1080px) { @@ -1693,7 +1693,7 @@ text-transform: uppercase; color: var(--ks-kinpaku); background: oklch(8% 0.006 95 / 0.72); - border: 1px solid var(--ks-gold-hairline, oklch(58% 0.065 82 / 0.32)); + border: 1px solid var(--ks-rule); border-radius: 4px; padding: 3px 7px; opacity: 0; diff --git a/site/styles/kinpaku-kit.css b/site/styles/kinpaku-kit.css index acc01f55b..2b2e6085f 100644 --- a/site/styles/kinpaku-kit.css +++ b/site/styles/kinpaku-kit.css @@ -988,9 +988,9 @@ --color-paper: oklch(7% 0.006 95); --color-cream: oklch(9% 0.006 95); --color-bg: oklch(11% 0.006 95); - --color-mist: oklch(58% 0.065 82 / 0.22); - --color-charcoal: oklch(72% 0.025 82); - --color-ash: oklch(60% 0.018 82); + --color-mist: var(--ks-rule); + --color-charcoal: var(--ks-text-muted); + --color-ash: var(--ks-text-faint); --color-ink: var(--ks-champagne); --color-text: var(--ks-text); --color-accent: var(--ks-kinpaku); diff --git a/site/styles/light-mode.css b/site/styles/light-mode.css index bdc81401f..e3a374d28 100644 --- a/site/styles/light-mode.css +++ b/site/styles/light-mode.css @@ -141,7 +141,7 @@ html.light .kinpaku-chrome .site-header-github { html.light .kinpaku-chrome .site-header-github:hover { color: var(--ks-patina-deep); border-color: var(--ks-patina); - background: oklch(52% 0.11 188 / 0.08); + background: oklch(49% 0.08 188 / 0.08); } @media (max-width: 760px) { @@ -232,10 +232,6 @@ html.light body.docs-kinpaku :is( .docs-index-eyebrow, .docs-start-eyebrow, .docs-chooser-eyebrow, - .docs-live-eyebrow, - .docs-flow-eyebrow, - .docs-chains-eyebrow, - .docs-reference-eyebrow, .docs-start-num, .docs-flow-verb, .docs-flow-arrow, @@ -483,7 +479,7 @@ html.light .home-kinpaku .why-visual--ci { background: linear-gradient(90deg, oklch(77% 0.13 82 / 0.04) 1px, transparent 1px), linear-gradient(180deg, oklch(77% 0.13 82 / 0.03) 1px, transparent 1px), - radial-gradient(circle at 92% 12%, oklch(52% 0.11 188 / 0.08), transparent 18rem), + radial-gradient(circle at 92% 12%, oklch(49% 0.08 188 / 0.08), transparent 18rem), var(--ks-lacquer-raised); background-size: 34px 34px, 34px 34px, auto, auto; } @@ -552,7 +548,7 @@ html.light .docs-kinpaku .skills-sidebar { } html.light .docs-kinpaku .skills-sidebar-list a[aria-current="page"] { - background: var(--docs-accent-wash); + background: transparent; } html.light .docs-kinpaku .docs-tile, @@ -996,7 +992,7 @@ html.light .home-kinpaku .load-error { html.light .home-kinpaku .install-primary-howto { background: - linear-gradient(135deg, oklch(52% 0.11 188 / 0.06), transparent 44%), + linear-gradient(135deg, oklch(49% 0.08 188 / 0.06), transparent 44%), var(--ks-lacquer-raised); } @@ -1008,7 +1004,7 @@ html.light .home-kinpaku .foundation-plinth { html.light .home-kinpaku .foundation-column:hover .foundation-card { box-shadow: - inset 0 0 0 1px oklch(52% 0.11 188 / 0.10), + inset 0 0 0 1px oklch(49% 0.08 188 / 0.10), 0 24px 58px oklch(25% 0.02 95 / 0.08); } @@ -1020,11 +1016,11 @@ html.light .home-kinpaku :is(.spread-demo-area, .terminal-preview, .mobile-demo- --demo-text: oklch(28% 0.018 95); --demo-muted: oklch(45% 0.015 95); --demo-dim: oklch(58% 0.012 95); - --demo-warning-panel: oklch(94% 0.04 72); - --demo-danger-panel: oklch(94% 0.04 35); - --demo-info-panel: oklch(94% 0.04 205); - --demo-purple: oklch(52% 0.14 286); - --demo-purple-soft: oklch(92% 0.04 286); + --demo-warning-panel: oklch(89% 0.055 84); + --demo-danger-panel: oklch(58% 0.15 35 / 0.1); + --demo-info-panel: oklch(70% 0.12 188 / 0.1); + --demo-purple: var(--ks-kinpaku-rich); + --demo-purple-soft: oklch(89% 0.055 84); } html.light .home-kinpaku .spread-demo-area .demo-split-comparison .split-after, @@ -1049,7 +1045,7 @@ html.light .home-kinpaku .slop-callout { html.light .home-kinpaku .why-visual--writes, html.light .home-kinpaku .why-visual--extension { background: - radial-gradient(circle at 92% 12%, oklch(52% 0.11 188 / 0.08), transparent 18rem), + radial-gradient(circle at 92% 12%, oklch(49% 0.08 188 / 0.08), transparent 18rem), var(--ks-lacquer-raised); } @@ -1067,7 +1063,7 @@ html.light .home-kinpaku .why-visual--extension .why-browser-chrome { } html.light .home-kinpaku .why-writes-line--del { - color: oklch(46% 0.12 30); + color: oklch(52% 0.16 35); background: oklch(58% 0.15 35 / 0.10); } @@ -1096,7 +1092,7 @@ html.light .home-kinpaku .why-visual--v2 .v2-new-browser-frame { background: linear-gradient(90deg, oklch(77% 0.13 82 / 0.05) 1px, transparent 1px), linear-gradient(180deg, oklch(77% 0.13 82 / 0.04) 1px, transparent 1px), - radial-gradient(circle at 72% 58%, oklch(52% 0.11 188 / 0.06), transparent 7rem), + radial-gradient(circle at 72% 58%, oklch(49% 0.08 188 / 0.06), transparent 7rem), var(--ks-lacquer); } @@ -1406,12 +1402,12 @@ html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-dem border: 0; /* Light muted tone from the base sheet: these sit on the dark configure surface, where the light-theme text-muted token would vanish. */ - color: oklch(63% 0.024 82); + color: oklch(72% 0 0); } html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-voice, html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-voice { - color: oklch(63% 0.024 82); + color: oklch(72% 0 0); } html.light .home-kinpaku .live-demo-ctx-go, diff --git a/site/styles/live-mode.css b/site/styles/live-mode.css index 9734f7b10..f883ad3ad 100644 --- a/site/styles/live-mode.css +++ b/site/styles/live-mode.css @@ -161,7 +161,7 @@ background: var(--color-accent); } .live-demo-card--v3 { - background: oklch(96% 0.02 350); + background: var(--color-cream); border: 1px solid var(--color-accent-soft); } .live-demo-card--v3 h3 em { color: var(--color-accent); font-style: italic; } @@ -347,11 +347,11 @@ flex-shrink: 0; border-radius: 3px; overflow: hidden; - box-shadow: inset 0 0 0 1px oklch(58% 0.065 82 / 0.55); + box-shadow: inset 0 0 0 1px var(--color-mist); } .live-demo-gbar-dmd span:nth-child(1) { background: oklch(84% 0.19 80.46); } .live-demo-gbar-dmd span:nth-child(2) { background: oklch(70% 0.12 188); } -.live-demo-gbar-dmd span:nth-child(3) { background: oklch(84% 0.035 82); } +.live-demo-gbar-dmd span:nth-child(3) { background: oklch(91% 0 0); } .live-demo-gbar-dmd span:nth-child(4) { background: oklch(34% 0.014 82); } .live-demo-gbar-x { display: inline-flex; @@ -487,7 +487,7 @@ height: 18px; padding: 0; background: transparent; - color: oklch(63% 0.024 82); + color: var(--color-ash); border: 0; border-radius: 0; font-family: var(--font-body); @@ -504,7 +504,7 @@ } .live-demo-ctx-action:hover, .live-demo-ctx-count:hover { - color: oklch(84% 0.035 82); + color: var(--color-ink); } .live-demo-ctx-action-caret { font-size: 10px; @@ -541,9 +541,9 @@ height: 36px; padding: 0; background: transparent; - color: oklch(63% 0.024 82); + color: var(--color-ash); border: 0; - border-right: 1px solid oklch(22% 0 0); + box-shadow: inset -1px 0 0 oklch(22% 0 0); border-radius: 0; cursor: pointer; } @@ -551,7 +551,7 @@ display: inline-block; width: 1px; height: 13px; - background: oklch(94% 0.02 82); + background: var(--color-ink); margin-left: 2px; animation: liveDemoCaret 1s steps(1) infinite; } @@ -569,7 +569,7 @@ background: var(--color-accent); color: var(--color-paper); border: 0; - border-left: 1px solid oklch(22% 0 0); + box-shadow: inset 1px 0 0 oklch(22% 0 0); border-radius: 0; font-family: var(--font-body); font-size: 0; @@ -801,7 +801,7 @@ background: var(--color-cream); padding: 18px 22px; border: 0; - border-top: 3px solid var(--color-ink); + box-shadow: inset 0 3px 0 var(--color-ink); border-radius: 0; gap: 6px; } @@ -902,7 +902,7 @@ .live-demo-card--v3 { position: relative; background: - radial-gradient(circle at 20% 80%, oklch(92% 0.08 350) 0, transparent 45%), + radial-gradient(circle at 20% 80%, oklch(77% 0.13 82 / 0.16) 0, transparent 45%), var(--color-cream); border: 1px dashed var(--color-accent); border-radius: 10px; diff --git a/site/styles/main.css b/site/styles/main.css index 0a9bb5f5a..d542460c3 100644 --- a/site/styles/main.css +++ b/site/styles/main.css @@ -2576,7 +2576,7 @@ code { content: '\2713'; position: absolute; left: 0; - color: var(--color-success, #22c55e); + color: var(--color-success, var(--ks-patina)); font-weight: 600; } diff --git a/site/styles/sub-pages.css b/site/styles/sub-pages.css index 164f2b3ec..5418126cf 100644 --- a/site/styles/sub-pages.css +++ b/site/styles/sub-pages.css @@ -195,7 +195,7 @@ a { } .site-header-github-star { - color: #f59e0b; + color: var(--color-accent); width: 12px; height: 12px; flex-shrink: 0; @@ -1134,15 +1134,15 @@ main#main { } .rule-card-layer[data-layer="browser"] { - color: oklch(40% 0.12 230); - border-color: oklch(90% 0.05 230); - background: oklch(97% 0.02 230); + color: var(--ks-patina-deep); + border-color: oklch(70% 0.12 188 / 0.32); + background: oklch(70% 0.12 188 / 0.08); } .rule-card-layer[data-layer="llm"] { - color: oklch(45% 0.15 45); - border-color: oklch(92% 0.08 45); - background: oklch(98% 0.03 45); + color: var(--ks-kinpaku-deep); + border-color: oklch(77% 0.13 82 / 0.34); + background: oklch(77% 0.13 82 / 0.08); } /* Layer legend dl inside the How-to-read block. */ diff --git a/site/styles/tokens.css b/site/styles/tokens.css index 08d684b00..e6fb8b8ba 100644 --- a/site/styles/tokens.css +++ b/site/styles/tokens.css @@ -70,9 +70,9 @@ button, input, textarea, select { --color-text: var(--ks-text); --color-paper: oklch(7% 0.006 95); --color-cream: oklch(9% 0.006 95); - --color-charcoal: oklch(72% 0.025 82); - --color-ash: oklch(60% 0.018 82); - --color-mist: oklch(58% 0.065 82 / 0.22); + --color-charcoal: var(--ks-text-muted); + --color-ash: var(--ks-text-faint); + --color-mist: var(--ks-rule); --color-bg: oklch(11% 0.006 95); /* Accent — kinpaku gold (was magenta). */ diff --git a/site/styles/workflow.css b/site/styles/workflow.css index c5726c458..6e9013e0c 100644 --- a/site/styles/workflow.css +++ b/site/styles/workflow.css @@ -16,12 +16,12 @@ ============================================ */ /* Category accent colors */ -.magazine-spread[data-category="create"] { --spread-accent: oklch(55% 0.20 340); } -.magazine-spread[data-category="evaluate"] { --spread-accent: oklch(55% 0.20 300); } -.magazine-spread[data-category="refine"] { --spread-accent: oklch(55% 0.18 260); } -.magazine-spread[data-category="simplify"] { --spread-accent: oklch(55% 0.20 80); } -.magazine-spread[data-category="harden"] { --spread-accent: oklch(55% 0.20 160); } -.magazine-spread[data-category="system"] { --spread-accent: oklch(45% 0.05 0); } +.magazine-spread[data-category="create"] { --spread-accent: var(--color-accent); } +.magazine-spread[data-category="evaluate"] { --spread-accent: var(--ks-patina); } +.magazine-spread[data-category="refine"] { --spread-accent: var(--color-accent); } +.magazine-spread[data-category="simplify"] { --spread-accent: var(--ks-kinpaku-rich); } +.magazine-spread[data-category="harden"] { --spread-accent: var(--ks-patina); } +.magazine-spread[data-category="system"] { --spread-accent: var(--color-ash); } .magazine-container { position: relative; @@ -743,7 +743,7 @@ } .terminal-done { - color: var(--color-success, #22c55e); + color: var(--color-success, var(--ks-patina)); font-weight: 500; } @@ -2002,7 +2002,7 @@ .why-terminal-line { white-space: pre; } .why-terminal-line--prompt { color: oklch(90% 0 0); } .why-terminal-prompt { color: var(--color-accent); margin-right: 4px; } -.why-terminal-ok { color: oklch(75% 0.15 145); } +.why-terminal-ok { color: var(--ks-patina); } .why-terminal-arrow { color: var(--color-accent); } .why-terminal-line--hint { margin-top: 6px; @@ -2164,8 +2164,8 @@ border-radius: 999px; } .why-ci-status--fail { - color: oklch(78% 0.18 25); - background: oklch(30% 0.15 25 / 0.3); + color: var(--ks-vermilion); + background: oklch(58% 0.15 35 / 0.16); } .why-ci-body { padding: 14px; @@ -2185,11 +2185,11 @@ } .why-ci-issue code { background: transparent; - color: oklch(70% 0.12 220); + color: var(--ks-patina); padding: 0; font-size: 1em; } -.why-ci-x { color: oklch(75% 0.18 25); } +.why-ci-x { color: var(--ks-vermilion); } .why-ci-summary { margin-top: 8px; padding-top: 8px; @@ -2440,7 +2440,7 @@ /* ─ Panel 03: path + meta additions ─ */ .why-terminal-path { - color: oklch(88% 0.05 220); + color: var(--ks-patina); } .why-terminal-meta { color: oklch(58% 0 0); @@ -2524,7 +2524,7 @@ text-transform: uppercase; color: var(--color-accent); } -.why-flow-tile--agent .why-flow-tile-kicker { color: oklch(72% 0.15 350); } +.why-flow-tile--agent .why-flow-tile-kicker { color: var(--ks-patina); } .why-flow-tile-title { font-family: var(--font-display); font-style: italic; @@ -3173,8 +3173,8 @@ backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } -.antidote-pair-figure--before figcaption { color: oklch(35% 0.15 25); background: oklch(98% 0.04 25 / 0.9); } -.antidote-pair-figure--after figcaption { color: oklch(45% 0.18 145); background: oklch(96% 0.04 145 / 0.9); } +.antidote-pair-figure--before figcaption { color: var(--ks-vermilion); background: oklch(58% 0.15 35 / 0.1); } +.antidote-pair-figure--after figcaption { color: var(--ks-patina); background: oklch(70% 0.12 188 / 0.1); } .antidote-pair:hover .antidote-pair-figure { box-shadow: 0 14px 28px oklch(0% 0 0 / 0.1); } @@ -3326,7 +3326,7 @@ .ab-after-data-d { font-family: var(--font-mono); font-size: 10px; - color: oklch(45% 0.18 145); + color: var(--ks-patina); } /* After 03: one big honest insight */ diff --git a/skill/reference/hooks.md b/skill/reference/hooks.md index 0f5c1e207..253cd0fa8 100644 --- a/skill/reference/hooks.md +++ b/skill/reference/hooks.md @@ -4,7 +4,9 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. -This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. +This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. + +Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores. Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). @@ -19,8 +21,8 @@ The first argument is the action. Defaults to `status`. | `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. | | `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. | | `off` | Set `enabled: false` in `.impeccable/config.json`. | -| `ignore-rule ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | -| `ignore-file ` | Append `` to `ignoreFiles`. | +| `ignore-rule ` | Append `` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. | +| `ignore-file ` | Append `` to `detector.ignoreFiles`. | | `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | | `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. | | `reset` | Delete the project config, dedup cache, and Cursor pending queue. | diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index 6332a122a..753cbf7c0 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** - * `/impeccable hooks ` — manage the design hook - * via the `hook` key of .impeccable/config.json and .impeccable/config.local.json - * in the current project. + * `/impeccable hooks ` — manage the design hook runtime + * via the `hook` key and shared detector ignores via the `detector` key in + * .impeccable/config.json / .impeccable/config.local.json. * * Usage: * node hook-admin.mjs status # print current state @@ -120,23 +120,48 @@ function readRawConfigFile(filePath) { } } -// The hook settings to edit: the unified file's `hook` subtree. -function readRawConfig(cwd, opts = {}) { - const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw; - if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') { - return unified.hook; - } - return null; +const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']); + +function hookSection(unified) { + return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook) + ? unified.hook + : null; } -// Write the hook config back under the `hook` key of the unified file, leaving -// any sibling keys (e.g. updateCheck) untouched. -function writeConfig(cwd, hookConfig, opts = {}) { +function detectorSection(unified) { + return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.detector && typeof unified.detector === 'object' && !Array.isArray(unified.detector) + ? unified.detector + : null; +} + +function readRawHookConfig(cwd, opts = {}) { + const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw; + return hookSection(unified); +} + +function readRawDetectorConfig(cwd, opts = {}) { + const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw; + const merged = mergeDetectorConfig(hookSection(unified)); + return mergeDetectorConfig(detectorSection(unified), merged); +} + +function stripDetectorKeys(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const out = {}; + for (const [key, value] of Object.entries(raw)) { + if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value; + } + return out; +} + +// Write hook runtime config under `hook`, leaving detector filters in +// `detector` and preserving sibling keys such as updateCheck. +function writeHookConfig(cwd, hookConfig, opts = {}) { const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd); if (opts.local) ensureHookGitExcludes(cwd); const existingRaw = readRawConfigFile(filePath).raw; const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {}; - const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {}; + const existingHook = stripDetectorKeys(hookSection(existing)); // Merge over the existing hook object so fields the merge helpers don't manage // (consent, quiet, auditLog) survive a `/impeccable hooks` edit. const next = { ...existing, hook: { ...existingHook, ...hookConfig } }; @@ -145,15 +170,28 @@ function writeConfig(cwd, hookConfig, opts = {}) { return filePath; } -function mergeConfig(existing) { - // Persist the full shape so /impeccable hooks edits leave a complete file - // for the user to see, not an unhelpful `{"enabled":false}`. +function writeDetectorConfig(cwd, detectorConfig, opts = {}) { + const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd); + if (opts.local) ensureHookGitExcludes(cwd); + const existingRaw = readRawConfigFile(filePath).raw; + const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {}; + const nextHook = stripDetectorKeys(hookSection(existing)); + const existingDetector = mergeDetectorConfig(detectorSection(existing)); + const next = { + ...existing, + detector: mergeDetectorConfig(detectorConfig, existingDetector), + }; + if (Object.keys(nextHook).length > 0) next.hook = nextHook; + else delete next.hook; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n'); + return filePath; +} + +function mergeHookConfig(existing) { const base = existing && typeof existing === 'object' ? existing : {}; return { enabled: base.enabled === false ? false : true, - ignoreRules: Array.isArray(base.ignoreRules) ? Array.from(new Set(base.ignoreRules.map(String))) : [], - ignoreFiles: Array.isArray(base.ignoreFiles) ? Array.from(new Set(base.ignoreFiles.map(String))) : [], - ignoreValues: normalizeIgnoreValueEntries(base.ignoreValues || []), limits: { maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings, maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars, @@ -161,28 +199,54 @@ function mergeConfig(existing) { }; } -function mergeLocalConfig(existing) { +function mergeDetectorConfig(existing, seed = null) { const base = existing && typeof existing === 'object' ? existing : {}; - const out = {}; - if (Object.prototype.hasOwnProperty.call(base, 'enabled')) { - out.enabled = base.enabled === false ? false : true; + const out = seed ? { + ignoreRules: [...seed.ignoreRules], + ignoreFiles: [...seed.ignoreFiles], + ignoreValues: normalizeIgnoreValueEntries(seed.ignoreValues), + } : { + ignoreRules: [], + ignoreFiles: [], + ignoreValues: [], + }; + if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) { + out.designSystem = { ...seed.designSystem }; + } + if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) { + out.designSystem = { + ...(out.designSystem || {}), + enabled: base.designSystem.enabled === false ? false : true, + }; } if (Array.isArray(base.ignoreRules)) { - out.ignoreRules = Array.from(new Set(base.ignoreRules.map(String))); + out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)])); } if (Array.isArray(base.ignoreFiles)) { - out.ignoreFiles = Array.from(new Set(base.ignoreFiles.map(String))); + out.ignoreFiles = Array.from(new Set([...out.ignoreFiles, ...base.ignoreFiles.map(String)])); } - out.ignoreValues = normalizeIgnoreValueEntries(base.ignoreValues || []); - if (base.limits && typeof base.limits === 'object') { - const limits = {}; - if (Number.isFinite(base.limits.maxFindings)) limits.maxFindings = base.limits.maxFindings; - if (Number.isFinite(base.limits.maxChars)) limits.maxChars = base.limits.maxChars; - if (Object.keys(limits).length) out.limits = limits; + if (Array.isArray(base.ignoreValues)) { + out.ignoreValues = mergeIgnoreValueEntries(out.ignoreValues, base.ignoreValues); } return out; } +function mergeIgnoreValueEntries(existing, incoming) { + const map = new Map(); + for (const entry of normalizeIgnoreValueEntries(existing)) { + map.set(ignoreValueEntryKey(entry), entry); + } + for (const entry of normalizeIgnoreValueEntries(incoming)) { + map.set(ignoreValueEntryKey(entry), entry); + } + return Array.from(map.values()); +} + +function ignoreValueEntryKey(entry) { + const files = Array.isArray(entry.files) && entry.files.length > 0 ? entry.files.join('\x1f') : ''; + return `${entry.rule}\0${entry.value}\0${files}`; +} + function statusReport(cwd) { const shared = readRawConfigFile(getConfigPath(cwd)); const local = readRawConfigFile(getLocalConfigPath(cwd)); @@ -216,14 +280,14 @@ function statusReport(cwd) { } function setEnabled(cwd, value) { - const config = mergeConfig(readRawConfig(cwd)); + const config = mergeHookConfig(readRawHookConfig(cwd)); config.enabled = value; - const target = writeConfig(cwd, config); + const target = writeHookConfig(cwd, config); if (!value) { return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`; } - const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true }); + const localTarget = writeHookConfig(cwd, { consent: 'accepted' }, { local: true }); const repaired = repairHookManifests(cwd); const parts = [ `Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`, @@ -429,18 +493,18 @@ function addIgnoreRule(cwd, args) { if (rule === 'overused-font' && !parsed.allValues) { throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.'); } - const config = mergeConfig(readRawConfig(cwd)); + const config = mergeDetectorConfig(readRawDetectorConfig(cwd)); if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule); - writeConfig(cwd, config); - return `Added "${rule}" to ignoreRules. Current: ${config.ignoreRules.join(', ')}`; + writeDetectorConfig(cwd, config); + return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`; } function addIgnoreFile(cwd, glob) { if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"'); - const config = mergeConfig(readRawConfig(cwd)); + const config = mergeDetectorConfig(readRawDetectorConfig(cwd)); if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob); - writeConfig(cwd, config); - return `Added "${glob}" to ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`; + writeDetectorConfig(cwd, config); + return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`; } function parseIgnoreValueArgs(args) { @@ -489,9 +553,7 @@ function addIgnoreValue(cwd, args) { } const local = parsed.local; - const config = local - ? mergeLocalConfig(readRawConfig(cwd, { local: true })) - : mergeConfig(readRawConfig(cwd, { local: false })); + const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local })); const key = `${parsed.rule}\0${parsed.value}`; const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key); @@ -507,20 +569,20 @@ function addIgnoreValue(cwd, args) { config.ignoreValues.push(entry); } - const target = writeConfig(cwd, config, { local }); - const scope = local ? 'local ignoreValues' : 'shared ignoreValues'; + const target = writeDetectorConfig(cwd, config, { local }); + const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues'; return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`; } function reset(cwd) { const removed = []; // Unified files may hold non-hook keys (e.g. updateCheck); strip only the - // hook subtree and keep the rest, deleting the file only if nothing remains. + // hook/detector subtrees and keep the rest, deleting the file only if nothing remains. for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) { try { const raw = readRawConfigFile(filePath).raw; - if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue; - const { hook, ...rest } = raw; + if (!raw || typeof raw !== 'object' || Array.isArray(raw) || (!('hook' in raw) && !('detector' in raw))) continue; + const { hook, detector, ...rest } = raw; if (Object.keys(rest).length === 0) { fs.unlinkSync(filePath); } else { diff --git a/skill/scripts/hook-before-edit.mjs b/skill/scripts/hook-before-edit.mjs index 14a47f191..c9d31b5d8 100644 --- a/skill/scripts/hook-before-edit.mjs +++ b/skill/scripts/hook-before-edit.mjs @@ -18,6 +18,8 @@ import { EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, + appendDesignSystemNote, + designSystemOptions, filterFindings, loadDetector, matchesAnyGlob, @@ -415,10 +417,11 @@ async function main() { if (!detector || typeof detector.detectText !== 'function') { return allow({ ...audit, skipped: 'detector-missing', durationMs: Date.now() - started }); } + const scanOptions = designSystemOptions(config, detector, cwd); let findings = []; try { - findings = await detector.detectText(content, filePath); + findings = await detector.detectText(content, filePath, scanOptions); } catch { return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -433,7 +436,7 @@ async function main() { }); } - const message = cursorBlockMessage(filtered, filePath, config, cwd); + const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index eeb4f519d..f466ac968 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -73,6 +73,7 @@ export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, auditLog: null, + designSystem: { enabled: true }, ignoreRules: [], ignoreFiles: [], ignoreValues: [], @@ -135,10 +136,14 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { export function readConfig(cwd) { const config = cloneDefaultConfig(); - // Hook settings live under the `hook` key of config.json (shared) and - // config.local.json (per-developer, gitignored); local wins. - applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd)))); - applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd)))); + // Hook runtime settings live under `hook`; detector filters live under + // `detector`. Back-compat: older configs stored detector filters in `hook`, + // so read those first and let canonical `detector` settings win. + for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) { + const raw = safeReadJson(filePath); + applyConfigSource(config, hookSection(raw)); + applyDetectorConfigSource(config, detectorSection(raw)); + } return config; } @@ -148,6 +153,11 @@ function hookSection(raw) { return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null; } +function detectorSection(raw) { + if (!raw || typeof raw !== 'object') return null; + return raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null; +} + function numberOr(value, fallback) { return Number.isFinite(value) && value > 0 ? value : fallback; } @@ -158,10 +168,31 @@ function cloneDefaultConfig() { ignoreRules: [], ignoreFiles: [], ignoreValues: [], + designSystem: { ...DEFAULT_CONFIG.designSystem }, limits: { ...DEFAULT_CONFIG.limits }, }; } +function applyDetectorConfigSource(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 applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { @@ -173,15 +204,7 @@ function applyConfigSource(config, raw) { if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } - 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); - } + applyDetectorConfigSource(config, raw); if (raw.limits && typeof raw.limits === 'object') { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), @@ -208,6 +231,157 @@ 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 = []; @@ -217,6 +391,11 @@ export function normalizeIgnoreValueEntries(entries) { 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(); } @@ -231,14 +410,18 @@ export function normalizeIgnoreValueEntries(entries) { function mergeIgnoreValues(existing, incoming) { const map = new Map(); for (const entry of normalizeIgnoreValueEntries(existing)) { - map.set(`${entry.rule}\0${entry.value}`, entry); + 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}`, entry); + 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') : ''; +} + export function readCache(cwd) { const raw = safeReadJson(getCachePath(cwd)); if (!raw || typeof raw !== 'object' || raw.version !== 1) { @@ -447,13 +630,39 @@ function isIgnoredFindingValue(finding, ignoreValues) { const rule = normalizeIgnoreRule(finding.antipattern); const value = extractFindingIgnoreValue(finding); if (!rule || !value) return false; - return ignoreValues.some((entry) => entry.rule === rule && entry.value === value); + 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(path.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); - if (rule !== 'overused-font' && rule !== 'bounce-easing') return ''; + 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)); } @@ -520,7 +729,7 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { const known = new Set(fileEntry.findings || []); const fresh = []; for (const f of findings) { - const key = `${f.antipattern}:${f.line || 0}`; + const key = findingCacheKey(f); if (known.has(key)) continue; known.add(key); fresh.push(f); @@ -531,11 +740,21 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(`${f.antipattern}:${f.line || 0}`); + for (const f of findings) known.add(findingCacheKey(f)); fileEntry.findings = Array.from(known); ensureSession(cache, sessionId).updatedAt = Date.now(); } +function findingCacheKey(finding) { + const line = finding?.line || 0; + const value = extractFindingIgnoreValue(finding); + if (line > 0 && value) return `${finding.antipattern}:${line}:${value}`; + if (line > 0) return `${finding.antipattern}:${line}`; + if (value) return `${finding.antipattern}:0:${value}`; + const snippet = String(finding?.snippet || '').trim().slice(0, 80); + return snippet ? `${finding.antipattern}:0:${snippet}` : `${finding.antipattern}:0`; +} + export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; @@ -942,7 +1161,11 @@ export async function loadDetector(candidates = DETECTOR_CANDIDATES) { const found = candidates.find((c) => fs.existsSync(c)); if (!found) return null; const mod = await import(pathToFileURL(found)); - detectorCache = { detectText: mod.detectText, detectHtml: mod.detectHtml }; + detectorCache = { + detectText: mod.detectText, + detectHtml: mod.detectHtml, + loadDesignSystemForCwd: mod.loadDesignSystemForCwd, + }; return detectorCache; } @@ -999,6 +1222,22 @@ export function shouldEmitAckForFile(filePath) { return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase()); } +export function designSystemOptions(config, detector, projectCwd) { + if (config?.designSystem?.enabled === false) return {}; + if (!detector || typeof detector.loadDesignSystemForCwd !== 'function') return {}; + try { + const designSystem = detector.loadDesignSystemForCwd(projectCwd); + return designSystem ? { designSystem } : {}; + } catch { + return {}; + } +} + +export function appendDesignSystemNote(text, scanOptions) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`; +} + // The directive footer is the part of the hook output that steers model // behavior. Three intentional moves: // 1. **Imperative, not advisory.** "Handle these..." beats "Consider @@ -1086,6 +1325,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = persistCache(projectCwd, cache); return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } + const scanOptions = designSystemOptions(config, det, projectCwd); let pendingWinner = null; let cleanWinner = null; @@ -1143,9 +1383,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let findings; let detectorThrew = false; if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') { - try { findings = await det.detectHtml(filePath); } catch { findings = []; detectorThrew = true; } + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; } } else { - try { findings = await det.detectText(content, filePath); } catch { findings = []; detectorThrew = true; } + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; } } const filtered = filterFindings(findings || [], content, ext, config); @@ -1176,7 +1416,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }); + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1208,7 +1448,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) { - const text = renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }); + const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1242,7 +1482,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) { - const text = renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }); + const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), diff --git a/skill/scripts/lib/design-parser.mjs b/skill/scripts/lib/design-parser.mjs index 82a5cd7dc..a4b9e9483 100644 --- a/skill/scripts/lib/design-parser.mjs +++ b/skill/scripts/lib/design-parser.mjs @@ -62,7 +62,7 @@ function parseYamlSubset(yaml) { stack.pop(); } - const key = content.slice(0, colonIdx).trim(); + const key = unquoteYamlKey(content.slice(0, colonIdx).trim()); const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim()); const parent = stack[stack.length - 1].obj; @@ -93,6 +93,13 @@ function findTopLevelColon(s) { return -1; } +function unquoteYamlKey(key) { + if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { + return key.slice(1, -1); + } + return key; +} + function stripInlineYamlComment(s) { let inQuote = null; for (let i = 0; i < s.length; i++) { diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 221fed9c6..3c2e72555 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -2681,12 +2681,12 @@ }); const check = el('span', { fontSize: '15px', lineHeight: '1', flexShrink: '0', - color: 'oklch(45% 0.15 145)', + color: 'oklch(45% 0.18 145)', }); check.textContent = '\u2713'; row.appendChild(check); const label = el('span', { - fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600', + fontSize: '12px', color: 'oklch(49% 0.08 188)', fontWeight: '600', }); label.textContent = 'Variant applied'; row.appendChild(label); @@ -8192,7 +8192,7 @@ void main() { const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; - const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; + const AGENT_DISCONNECTED_MARK = 'oklch(62% 0 0 / 0.78)'; const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; @@ -8259,8 +8259,8 @@ void main() { // Neutral hairline for internal control borders / dividers (was a warm // gold rule that read as muddy champagne edges on the pill / input / count). hairline: 'oklch(92% 0 0 / 0.12)', - text: 'oklch(84% 0.035 82)', - textDim: 'oklch(63% 0.024 82)', + text: 'oklch(91% 0 0)', + textDim: 'oklch(72% 0 0)', accent: C.brand, accentSoft: C.brandSoft, exitHover: 'oklch(58% 0.15 35 / 0.18)', @@ -9064,9 +9064,9 @@ void main() { '#' + PREFIX + '-page-chat[data-voice-listening="true"] { border-color: oklch(70% 0.12 188 / 0.45); }' + '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + - '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0 0); opacity: 1; }' + '#' + PREFIX + '-page-chat-input { caret-color: oklch(84% 0.19 80.46); }' + - '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0 0); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } @@ -9306,7 +9306,7 @@ void main() { const agentDot = el('span', { position: 'absolute', right: '-1px', bottom: '7px', width: '6px', height: '6px', borderRadius: '50%', - background: 'oklch(78% 0.14 75)', + background: 'oklch(77% 0.13 82)', boxShadow: '0 0 0 2px ' + P.surface, display: 'none', pointerEvents: 'none', }); @@ -9408,11 +9408,11 @@ void main() { // DESIGN.md panel toggle - quartet of color squares as the mark. const designBtn = makeIconBtn({ id: PREFIX + '-design-toggle', - svg: ` + svg: ` - - + + `, label: 'DESIGN.md', ariaLabel: 'Toggle DESIGN.md panel', @@ -9996,8 +9996,8 @@ void main() { meta: 'oklch(55% 0 0)', hairline: 'oklch(88% 0 0)', hairlineSoft: 'oklch(92% 0 0)', - amber: 'oklch(70% 0.13 65)', // stale-hint accent - amberBg: 'oklch(95% 0.05 80)', + amber: 'oklch(77% 0.13 82)', // stale-hint accent + amberBg: 'oklch(89% 0.055 84)', }; function designPanelCss(BP) { @@ -10088,7 +10088,7 @@ void main() { } .empty strong { color: ${DP.ink}; display: block; margin-bottom: 6px; font-size: 14px; } .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } - .error { color: oklch(45% 0.15 25); } + .error { color: oklch(58% 0.15 35); } /* Stale hint */ .stale { @@ -10240,8 +10240,8 @@ void main() { content: ''; position: absolute; left: 4px; top: 13px; width: 8px; height: 8px; border-radius: 50%; } - .coll .do::before { background: oklch(62% 0.16 145); } - .coll .dont::before { background: oklch(58% 0.22 25); } + .coll .do::before { background: oklch(45% 0.18 145); } + .coll .dont::before { background: oklch(58% 0.15 35); } .coll .overview-body { font-size: 12px; line-height: 1.55; color: ${DP.ink2}; diff --git a/tests/cli-ignores.test.js b/tests/cli-ignores.test.js new file mode 100644 index 000000000..697c77d22 --- /dev/null +++ b/tests/cli-ignores.test.js @@ -0,0 +1,97 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const CLI = resolve('cli/bin/cli.js'); + +describe('impeccable ignores CLI', () => { + let root; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'imp-ignores-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function run(args, options = {}) { + const result = spawnSync(process.execPath, [CLI, 'ignores', ...args], { + cwd: root, + encoding: 'utf-8', + ...options, + }); + if (result.error) throw result.error; + return result; + } + + function readConfig(name = 'config.json') { + return JSON.parse(readFileSync(join(root, '.impeccable', name), 'utf-8')); + } + + test('adds and lists shared file and value ignores under detector', () => { + expect(run(['add-file', 'src/legacy/**']).status).toBe(0); + expect(run(['add-value', 'overused-font', 'Inter', '--reason', 'Brand font']).status).toBe(0); + + const raw = readConfig(); + expect(raw.hook).toBeUndefined(); + expect(raw.detector.ignoreFiles).toEqual(['src/legacy/**']); + expect(raw.detector.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason }))).toEqual([ + { rule: 'overused-font', value: 'inter', reason: 'Brand font' }, + ]); + expect(raw.detector.designSystem).toBeUndefined(); + + const listed = run(['list']); + expect(listed.status).toBe(0); + expect(listed.stdout).toContain('ignoreFiles: src/legacy/**'); + expect(listed.stdout).toContain('overused-font=inter'); + }); + + test('supports scoped wildcard value ignores and removal', () => { + expect(run(['add-value', 'design-system-color', '*', '--file', 'site/styles/demo.css']).status).toBe(0); + let raw = readConfig(); + expect(raw.detector.ignoreValues).toEqual([ + expect.objectContaining({ + rule: 'design-system-color', + value: '*', + files: ['site/styles/demo.css'], + }), + ]); + + expect(run(['remove-value', 'design-system-color', '*', '--file', 'site/styles/demo.css']).status).toBe(0); + raw = readConfig(); + expect(raw.detector.ignoreValues).toEqual([]); + }); + + test('rejects broad wildcard value ignores', () => { + const result = run(['add-value', 'design-system-color', '*']); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Wildcard value ignores must be scoped'); + expect(existsSync(join(root, '.impeccable', 'config.json'))).toBe(false); + }); + + test('removes an existing broad wildcard value ignore', () => { + mkdirSync(join(root, '.impeccable'), { recursive: true }); + writeFileSync(join(root, '.impeccable', 'config.json'), JSON.stringify({ + detector: { + ignoreValues: [{ rule: 'design-system-color', value: '*' }], + }, + })); + + const result = run(['remove-value', 'design-system-color', '*']); + expect(result.status).toBe(0); + expect(readConfig().detector.ignoreValues).toEqual([]); + }); + + test('writes local ignores without overriding shared design-system config', () => { + expect(run(['add-value', 'overused-font', 'Inter', '--local']).status).toBe(0); + + const local = readConfig('config.local.json'); + expect(local.detector.ignoreValues.map(({ rule, value }) => ({ rule, value }))).toEqual([ + { rule: 'overused-font', value: 'inter' }, + ]); + expect(local.detector.designSystem).toBeUndefined(); + }); +}); diff --git a/tests/design-parser.test.mjs b/tests/design-parser.test.mjs index ea77ee7e5..16986cab1 100644 --- a/tests/design-parser.test.mjs +++ b/tests/design-parser.test.mjs @@ -120,4 +120,26 @@ Prose. assert.equal(model.frontmatter.colors['kinpaku-gold'], 'oklch(84% 0.19 80.46)'); assert.equal(model.frontmatter.colors['gold-hairline'], 'oklch(58% 0.065 82 / 0.32)'); }); + + it('normalizes quoted YAML keys in token maps', () => { + const md = `--- +rounded: + "2xl": "80px" + '3xl': "96px" +colors: + "brand-gold": "#d9a531" +--- + +# Design System: Quoted Keys + +## 1. Overview + +Prose. +`; + const model = parseDesignMd(md); + assert.equal(model.frontmatter.rounded['2xl'], '80px'); + assert.equal(model.frontmatter.rounded['3xl'], '96px'); + assert.equal(model.frontmatter.colors['brand-gold'], '#d9a531'); + assert.equal(model.frontmatter.rounded['"2xl"'], undefined); + }); }); diff --git a/tests/design-system.test.mjs b/tests/design-system.test.mjs new file mode 100644 index 000000000..707b0d46a --- /dev/null +++ b/tests/design-system.test.mjs @@ -0,0 +1,308 @@ +/** + * Design-system normalization and source-rule tests. + * Run with: node --test tests/design-system.test.mjs + */ + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + checkSourceDesignSystem, + collectStaticDesignSystemFindings, + isAllowedColorRaw, + isAllowedFont, + isAllowedRadiusRaw, + loadDesignSystemForCwd, + normalizeDesignSystem, +} from '../cli/engine/design-system.mjs'; + +const tempDirs = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-design-system-')); + tempDirs.push(dir); + return dir; +} + +function sampleDesignSystem() { + return normalizeDesignSystem({ + frontmatter: { + typography: { + display: { fontFamily: 'Avenir Next, Georgia, serif' }, + body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif' }, + }, + colors: { + ink: '#241f1a', + paper: '#f7f4ee', + accent: '#b8422e', + gold: 'oklch(84% 0.19 80.46)', + }, + rounded: { + sm: '4px', + md: '8px', + '"2xl"': '80px', + full: '999px', + }, + }, + sidecar: { + extensions: { + colorMeta: { + gold: { + canonical: 'oklch(84% 0.19 80.46)', + tonalRamp: ['#d9a531', '#b98518'], + }, + }, + roundedMeta: { + soft: { + canonical: '12px', + values: ['24px'], + }, + }, + }, + }, + }); +} + +afterEach(() => { + while (tempDirs.length) { + fs.rmSync(tempDirs.pop(), { recursive: true, force: true }); + } +}); + +describe('normalizeDesignSystem()', () => { + it('normalizes typography, colors, sidecar ramps, and quoted rounded keys', () => { + const designSystem = sampleDesignSystem(); + + assert.equal(isAllowedFont('avenir next', designSystem), true); + assert.equal(isAllowedFont('ibm plex sans', designSystem), true); + assert.equal(isAllowedFont('system-ui', designSystem), true); + assert.equal(isAllowedFont('poppins', designSystem), false); + + assert.equal(isAllowedColorRaw('#241f1a', designSystem), true); + assert.equal(isAllowedColorRaw('oklch(84% 0.19 80.46 / 0.5)', designSystem), true); + assert.equal(isAllowedColorRaw('#d9a531', designSystem), true); + assert.equal(isAllowedColorRaw('#ff00aa', designSystem), false); + assert.equal(isAllowedColorRaw('var(--brand-accent)', designSystem), true); + assert.equal(isAllowedColorRaw('currentColor', designSystem), true); + + assert.equal(isAllowedRadiusRaw('0', designSystem), true); + assert.equal(isAllowedRadiusRaw('50%', designSystem), true); + assert.equal(isAllowedRadiusRaw('80px', designSystem), true); + assert.equal(isAllowedRadiusRaw('12px', designSystem), true); + assert.equal(isAllowedRadiusRaw('24px', designSystem), true); + assert.equal(isAllowedRadiusRaw('100px', designSystem), true); + assert.equal(isAllowedRadiusRaw('9999px', designSystem), true); + assert.equal(isAllowedRadiusRaw('18px', designSystem), false); + }); +}); + +describe('loadDesignSystemForCwd()', () => { + it('loads DESIGN.md plus .impeccable/design.json and marks stale sidecars', () => { + const cwd = mkTmp(); + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + const designMd = path.join(cwd, 'DESIGN.md'); + const sidecarJson = path.join(cwd, '.impeccable', 'design.json'); + + fs.writeFileSync(designMd, `--- +typography: + body: + fontFamily: "IBM Plex Sans, Arial, sans-serif" +colors: + ink: "#241f1a" +rounded: + "2xl": "80px" +--- + +# Design System +`); + fs.writeFileSync(sidecarJson, JSON.stringify({ + extensions: { + colorMeta: { + accent: { + canonical: '#b8422e', + tonalRamp: ['#d55a42'], + }, + }, + roundedMeta: { + lg: { canonical: '24px' }, + }, + }, + })); + + fs.utimesSync(sidecarJson, new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z')); + fs.utimesSync(designMd, new Date('2026-01-02T00:00:00Z'), new Date('2026-01-02T00:00:00Z')); + + const loaded = loadDesignSystemForCwd(cwd); + assert.equal(loaded.present, true); + assert.equal(loaded.sourcePath, designMd); + assert.equal(loaded.sidecarPath, sidecarJson); + assert.equal(loaded.mdNewerThanJson, true); + assert.equal(isAllowedColorRaw('#d55a42', loaded), true); + assert.equal(isAllowedRadiusRaw('80px', loaded), true); + assert.equal(isAllowedRadiusRaw('24px', loaded), true); + }); +}); + +describe('checkSourceDesignSystem()', () => { + it('reports source fonts, literal colors, and radii outside DESIGN.md', () => { + const designSystem = sampleDesignSystem(); + const findings = checkSourceDesignSystem(` +.good { + font-family: "IBM Plex Sans", Arial, sans-serif; + color: #241f1a; + background: rgba(184, 66, 46, 0.45); + border-radius: 8px; +} + +.bad { + font-family: "Poppins", sans-serif; + color: #ff00aa; + background: rgba(255, 0, 170, 1); + border-radius: 18px; +} +`, '/tmp/source.css', { designSystem }); + + assert.deepEqual( + findings.map((item) => item.antipattern), + ['design-system-font', 'design-system-color', 'design-system-color', 'design-system-radius'], + ); + assert.deepEqual( + findings.map((item) => item.ignoreValue), + ['Poppins', '#ff00aa', 'rgba(255, 0, 170, 1)', '18px'], + ); + }); + + it('strips CSS priority markers before checking font-family declarations', () => { + const designSystem = sampleDesignSystem(); + const findings = checkSourceDesignSystem(` +.good { + font-family: "IBM Plex Sans", Arial, sans-serif !important; +} + +.also-good { + font-family: "Avenir Next" !important; +} + +.bad { + font-family: "Poppins" !important; +} +`, '/tmp/important.css', { designSystem }); + + assert.deepEqual( + findings.map((item) => item.ignoreValue), + ['Poppins'], + ); + }); + + it('does not treat issue labels, HTML entities, or font variables as literal design values', () => { + const designSystem = sampleDesignSystem(); + const findings = checkSourceDesignSystem(` +#155 + +const MONO = 'SFMono-Regular, Roboto Mono, Consolas, monospace'; +const FONT = 'IBM Plex Sans, Arial, sans-serif'; +const COLOR_SAMPLE = 'rgba(255, 0, 170, 1)'; +const COLOR_NOTE = 'oklch(60% 0.2 20)'; +button.innerHTML = \`Pick\`; +scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px;'; +.demo [style*="background: #fef3c7"] { + border-color: #ff00aa; +} + +.bad { + font-family: "Poppins", sans-serif; + color: #cc00ff; +} +`, '/tmp/source.jsx', { designSystem }); + + assert.deepEqual( + findings.map((item) => item.ignoreValue), + ['#ff00aa', 'Poppins', '#cc00ff'], + ); + }); +}); + +describe('collectStaticDesignSystemFindings()', () => { + function makeElement(tagName, { text = '', attrs = {}, style = {}, parentElement = null } = {}) { + return { + tagName: tagName.toUpperCase(), + textContent: text, + parentElement, + _style: style, + childNodes: text ? [{ nodeType: 3, textContent: text }] : [], + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; + }, + }; + } + + function makeWindow() { + const defaults = { + color: 'rgb(36, 31, 26)', + backgroundColor: 'rgba(0, 0, 0, 0)', + borderTopWidth: '0px', + borderRightWidth: '0px', + borderBottomWidth: '0px', + borderLeftWidth: '0px', + borderTopColor: 'rgb(36, 31, 26)', + borderRightColor: 'rgb(36, 31, 26)', + borderBottomColor: 'rgb(36, 31, 26)', + borderLeftColor: 'rgb(36, 31, 26)', + outlineWidth: '0px', + outlineColor: 'rgb(36, 31, 26)', + borderRadius: '0px', + display: '', + visibility: 'visible', + fontFamily: 'IBM Plex Sans, Arial, sans-serif', + }; + return { + getComputedStyle(el) { + return { ...defaults, ...(el?._style || {}) }; + }, + }; + } + + it('skips non-rendered tags and hidden elements in the static DOM pass', () => { + const designSystem = sampleDesignSystem(); + const hiddenParent = makeElement('section', { attrs: { hidden: '' } }); + const elements = [ + makeElement('style', { + text: '.hidden { color: #ff00aa; font-family: Poppins; }', + style: { color: 'rgb(0, 0, 0)', fontFamily: 'Poppins, sans-serif' }, + }), + makeElement('script', { + text: 'const color = "#ff00aa";', + style: { color: 'rgb(0, 0, 0)', fontFamily: 'Poppins, sans-serif' }, + }), + makeElement('div', { + text: 'Hidden Drift', + parentElement: hiddenParent, + style: { color: 'rgb(255, 0, 170)', fontFamily: 'Poppins, sans-serif', borderRadius: '18px' }, + }), + makeElement('div', { + text: 'Display None Drift', + style: { display: 'none', color: 'rgb(255, 0, 170)', fontFamily: 'Poppins, sans-serif', borderRadius: '18px' }, + }), + makeElement('div', { + text: 'Visible Drift', + style: { color: 'rgb(255, 0, 170)', fontFamily: 'Poppins, sans-serif', borderRadius: '18px' }, + }), + ]; + const findings = collectStaticDesignSystemFindings( + { querySelectorAll: () => elements }, + makeWindow(), + '/tmp/page.html', + designSystem, + ); + const snippets = findings.map(item => item.snippet).join('\n'); + + assert.match(snippets, /Visible Drift/); + assert.doesNotMatch(snippets, /Hidden Drift/); + assert.doesNotMatch(snippets, /Display None Drift/); + assert.doesNotMatch(snippets, /\.hidden/); + assert.doesNotMatch(snippets, /const color/); + }); +}); diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 9511a6c89..31eb69a26 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -19,7 +19,8 @@ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createBrowserDetector, detectUrl } from '../cli/engine/detect-antipatterns.mjs'; +import { createBrowserDetector, detectUrl, normalizeDesignSystem } from '../cli/engine/detect-antipatterns.mjs'; +import { filterDetectionFindings } from '../cli/lib/impeccable-config.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); @@ -116,6 +117,64 @@ describe('detectUrl — browser-only fixtures', () => { assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1); }); + it('design-system: URL scans apply injected design context', async () => { + const designSystem = normalizeDesignSystem({ + frontmatter: { + typography: { + display: { fontFamily: 'Avenir Next, Georgia, serif' }, + body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif' }, + }, + colors: { + ink: '#241f1a', + paper: '#f7f4ee', + surface: '#ffffff', + accent: '#b8422e', + border: '#d4c7b9', + }, + rounded: { + sm: '4px', + md: '8px', + '"2xl"': '32px', + full: '999px', + }, + }, + sidecar: { + extensions: { + colorMeta: { + accent: { + canonical: '#b8422e', + tonalRamp: ['#923524', '#d55a42'], + }, + }, + }, + }, + }); + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/design-system.html`, { + designSystem, + visualContrast: false, + }); + const designFindings = f.filter(r => r.antipattern.startsWith('design-system-')); + const snippets = designFindings.map(r => r.snippet || '').join('\n'); + + assert.ok(designFindings.some(r => r.antipattern === 'design-system-font'), 'expected unsupported font'); + assert.ok(designFindings.some(r => r.antipattern === 'design-system-color'), 'expected undocumented colors'); + assert.ok(designFindings.some(r => r.antipattern === 'design-system-radius'), 'expected undocumented radius'); + assert.match(snippets, /Flag Font Unsupported/); + assert.match(snippets, /Flag Color Hot Pink/); + assert.match(snippets, /Flag Radius Eighteen/); + assert.doesNotMatch(snippets, /Pass Mid Pill Radius/); + + const filtered = filterDetectionFindings(f, { + ignoreRules: [], + ignoreValues: [{ rule: 'design-system-font', value: 'poppins' }], + }); + assert.equal( + filtered.some(r => r.antipattern === 'design-system-font' && /poppins/i.test(r.ignoreValue || r.snippet || '')), + false, + 'URL design-system findings should carry ignoreValue for CLI suppressions', + ); + }); + it('clipped-overflow-container: utility-named popovers still flag when clipped', async () => { const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/clipped-overflow-container.html`); const snippets = f diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 5eb6fe0cc..ee8eeae5a 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -10,6 +10,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { detectHtml, + normalizeDesignSystem, } from '../cli/engine/detect-antipatterns.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -243,6 +244,89 @@ describe('detectHtml — static HTML/CSS fixtures', () => { assert.equal(f.length, 0); }); + it('design-system: flags only values outside the provided DESIGN.md tokens', async () => { + const designSystem = normalizeDesignSystem({ + frontmatter: { + typography: { + display: { fontFamily: 'Avenir Next, Georgia, serif' }, + body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif' }, + }, + colors: { + ink: '#241f1a', + paper: '#f7f4ee', + surface: '#ffffff', + accent: '#b8422e', + border: '#d4c7b9', + }, + rounded: { + sm: '4px', + md: '8px', + '"2xl"': '32px', + full: '999px', + }, + }, + sidecar: { + extensions: { + colorMeta: { + accent: { + canonical: '#b8422e', + tonalRamp: ['#923524', '#d55a42'], + }, + }, + }, + }, + }); + const f = await detectHtml(path.join(FIXTURES, 'design-system.html'), { designSystem }); + const designFindings = f.filter((r) => r.antipattern.startsWith('design-system-')); + const snippets = designFindings.map((r) => r.snippet).join('\n'); + + assert.ok(designFindings.some((r) => r.antipattern === 'design-system-font'), 'expected unsupported font'); + assert.ok(designFindings.some((r) => r.antipattern === 'design-system-color'), 'expected undocumented colors'); + assert.ok(designFindings.some((r) => r.antipattern === 'design-system-radius'), 'expected undocumented radius'); + assert.ok( + designFindings.some((r) => r.antipattern === 'design-system-font' && /Google Fonts: Poppins/.test(r.snippet || '')), + 'expected source-level Google Fonts usage in HTML to be flagged', + ); + assert.doesNotMatch(snippets, /Undocumented color #ff00aa/, 'source and computed color findings should not duplicate'); + assert.doesNotMatch(snippets, /font-family: Poppins/, 'source and computed font findings should not duplicate'); + assert.doesNotMatch(snippets, /border-radius: 18px is outside/, 'source and computed radius findings should not duplicate'); + assert.doesNotMatch(snippets, /on style "\.design-system-fixture/, 'static DOM design pass should skip + + + +
Flag Font Unsupported
+
Flag Color Hot Pink
+
Flag Background Cyan
+
Flag Border Teal
+
Flag Radius Eighteen
+
Flag Google Font Source
+ +
Pass Display Font
+
Pass Generic Font
+
Pass Token Color
+
Pass Alpha Color
+
Pass Close Color
+
Pass Ramp Color
+
Pass Zero Radius
+
Pass Percent Radius
+
Pass Scale Radius
+
Pass Mid Pill Radius
+
Pass Pill Radius
+
diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index b360f14a5..e050eae7e 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -133,7 +133,7 @@ describe('readConfig()', () => { assert.equal(cfg.limits.maxFindings, DEFAULT_CONFIG.limits.maxFindings); }); - it('parses enabled, ignoreRules, ignoreFiles, limits', () => { + it('parses hook runtime and legacy hook detector filters', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { @@ -158,26 +158,30 @@ describe('readConfig()', () => { fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false, + minSeverity: 'error', + limits: { maxFindings: 2, maxChars: 1000 }, + }, + detector: { ignoreRules: ['side-tab'], ignoreFiles: ['src/legacy/**'], ignoreValues: [ { rule: 'overused-font', value: 'inter', reason: 'team default' }, ], - minSeverity: 'error', - limits: { maxFindings: 2, maxChars: 1000 }, }, })); fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { enabled: true, + minSeverity: 'warning', + limits: { maxFindings: 4 }, + }, + detector: { ignoreRules: ['gradient-text', 'side-tab'], ignoreFiles: ['src/local/**'], ignoreValues: [ { rule: 'overused-font', value: 'Roboto' }, { rule: 'overused-font', value: 'Inter', reason: 'local override' }, ], - minSeverity: 'warning', - limits: { maxFindings: 4 }, }, })); @@ -220,10 +224,12 @@ describe('readConfig()', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true, auditLog: '~/hook.ndjson' }, + detector: { designSystem: { enabled: false } }, })); const cfg = readConfig(cwd); assert.equal(cfg.quiet, true); assert.equal(cfg.auditLog, '~/hook.ndjson'); + assert.deepEqual(cfg.designSystem, { enabled: false }); }); }); @@ -245,6 +251,22 @@ describe('readCache / persistCache / bumpEditCount', () => { assert.ok(file.findings.includes('side-tab:12')); }); + it('keeps same-line value-specific findings distinct in the cache', () => { + const cache = readCache(cwd); + const hotPink = { + ...finding('design-system-color', 7, { snippet: 'Undocumented color #ff00aa' }), + ignoreValue: '#ff00aa', + }; + const cyan = { + ...finding('design-system-color', 7, { snippet: 'Undocumented color rgb(20, 180, 220)' }), + ignoreValue: 'rgb(20, 180, 220)', + }; + + assert.deepEqual(dedupeAgainstCache([hotPink, cyan], cache, 'sid-1', '/x/a.css'), [hotPink, cyan]); + rememberFindings(cache, 'sid-1', '/x/a.css', [hotPink]); + assert.deepEqual(dedupeAgainstCache([hotPink, cyan], cache, 'sid-1', '/x/a.css'), [cyan]); + }); + it('garbage-collects oldest sessions over CACHE_MAX_SESSIONS', () => { const cache = readCache(cwd); // Stamp 10 sessions, each with a unique updatedAt so ordering is stable. @@ -352,6 +374,63 @@ describe('filterFindings()', () => { assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['overused-font:2', 'bounce-easing:4', 'side-tab:3']); }); + it('scopes ignoreValues to file globs when files are provided', () => { + const findings = [ + { ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: '#8b5cf6' }, + { ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/feature.css' }), ignoreValue: '#8b5cf6' }, + { ...finding('design-system-color', 3, { file: '/tmp/project/site/styles/home-kinpaku.css' }), ignoreValue: 'oklch(60% 0.25 350 / 0.22)' }, + ]; + const filtered = filterFindings(findings, '', '.css', { + ignoreRules: [], + ignoreValues: [ + { rule: 'design-system-color', value: '#8b5cf6', files: ['site/styles/main.css'] }, + { rule: 'design-system-color', value: 'oklch(60% 0.25 350 / 0.22)', file: 'site/styles/home-kinpaku.css' }, + ], + limits: DEFAULT_CONFIG.limits, + }); + assert.deepEqual(filtered.map((f) => `${f.file}:${f.line}`), ['/tmp/project/site/styles/feature.css:2']); + }); + + it('matches equivalent design-system color ignore values', () => { + const findings = [ + { ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/rgb.css' }), ignoreValue: 'rgb(139, 92, 246)' }, + { ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/hex.css' }), ignoreValue: '#8b5cf6' }, + { ...finding('design-system-color', 3, { file: '/tmp/project/site/styles/alpha.css' }), ignoreValue: 'rgba(139, 92, 246, 0.5)' }, + { ...finding('design-system-color', 4, { file: '/tmp/project/site/styles/other.css' }), ignoreValue: '#8b5cf7' }, + { ...finding('design-system-radius', 5, { file: '/tmp/project/site/styles/radius.css' }), ignoreValue: 'rgb(139, 92, 246)' }, + ]; + const filtered = filterFindings(findings, '', '.css', { + ignoreRules: [], + ignoreValues: [ + { rule: 'design-system-color', value: '#8b5cf6' }, + { rule: 'design-system-color', value: 'rgb(139 92 246 / 100%)' }, + ], + limits: DEFAULT_CONFIG.limits, + }); + assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), [ + 'design-system-color:3', + 'design-system-color:4', + 'design-system-radius:5', + ]); + }); + + it('allows wildcard ignoreValues only when scoped to files', () => { + const findings = [ + { ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: '#8b5cf6' }, + { ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/feature.css' }), ignoreValue: '#8b5cf6' }, + { ...finding('design-system-font', 3, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: 'Inter' }, + ]; + const filtered = filterFindings(findings, '', '.css', { + ignoreRules: [], + ignoreValues: [ + { rule: 'design-system-color', value: '*', files: ['site/styles/main.css'] }, + { rule: 'design-system-font', value: '*' }, + ], + limits: DEFAULT_CONFIG.limits, + }); + assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['design-system-color:2', 'design-system-font:3']); + }); + it('extracts overused-font values from primary, CSS, and Google font snippets', () => { assert.equal( extractFindingIgnoreValue(finding('overused-font', 1, { snippet: 'Primary font: Open Sans (80% of text)' })), @@ -403,8 +482,9 @@ describe('hook-admin.mjs', () => { const out = runAdmin(['ignore-value', 'overused-font', 'Inter', '--reason', 'User confirmed Inter']); assert.match(out, /overused-font=inter/); assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false); - const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook; - assert.equal(shared.enabled, true); + const raw = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')); + assert.equal(raw.hook, undefined); + const shared = raw.detector; assert.deepEqual(shared.ignoreRules, []); assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [ { rule: 'overused-font', value: 'inter', reason: 'User confirmed Inter' }, @@ -415,7 +495,7 @@ describe('hook-admin.mjs', () => { it('ignore-value --shared remains accepted for shared config', () => { runAdmin(['ignore-value', 'overused-font', 'Open', 'Sans', '--shared', '--reason', 'Brand font']); assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false); - const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook; + const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [ { rule: 'overused-font', value: 'open sans', reason: 'Brand font' }, ]); @@ -425,8 +505,10 @@ describe('hook-admin.mjs', () => { runAdmin(['ignore-value', 'overused-font', 'Inter', '--local']); runAdmin(['ignore-value', 'OVERUSED-FONT', '"Inter"', '--local', '--reason', 'Still intentional']); assert.equal(fs.existsSync(getConfigPath(cwd)), false); - const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook; - assert.equal(local.enabled, undefined, 'local ignore should not override shared enabled state'); + const raw = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')); + assert.equal(raw.hook, undefined); + const local = raw.detector; + assert.equal(local.designSystem, undefined, 'local ignore should not override shared design-system state'); assert.equal(local.ignoreValues.length, 1); assert.equal(local.ignoreValues[0].reason, 'Still intentional'); @@ -440,9 +522,9 @@ describe('hook-admin.mjs', () => { // A recorded per-developer consent in the local file... fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { consent: 'declined' } })); runAdmin(['ignore-value', 'overused-font', 'Inter', '--local']); - const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook; - assert.equal(local.consent, 'declined', 'consent must survive a local ignore-value edit'); - assert.equal(local.ignoreValues.length, 1); + const localRaw = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')); + assert.equal(localRaw.hook.consent, 'declined', 'consent must survive a local ignore-value edit'); + assert.equal(localRaw.detector.ignoreValues.length, 1); // ...and a shared quiet flag survives an on/off toggle. fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true } })); @@ -498,15 +580,15 @@ describe('hook-admin.mjs', () => { it('ignore-rule overused-font --all-values writes a whole-rule suppression', () => { const out = runAdmin(['ignore-rule', 'overused-font', '--all-values', '--reason', 'User asked to ignore overused fonts generally']); - assert.match(out, /Added "overused-font" to ignoreRules/); - const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook; + assert.match(out, /Added "overused-font" to detector\.ignoreRules/); + const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.ignoreRules, ['overused-font']); assert.deepEqual(shared.ignoreValues, []); }); it('ignore-rule still allows non-value rules without --all-values', () => { runAdmin(['ignore-rule', 'side-tab']); - const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook; + const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.ignoreRules, ['side-tab']); }); @@ -524,7 +606,7 @@ describe('hook-admin.mjs', () => { runAdmin(['ignore-file', 'src/ConfirmedCard.html']); - const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook; + const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.ignoreFiles, ['src/ConfirmedCard.html']); const r = await runHook({ @@ -726,6 +808,48 @@ describe('runHook()', () => { return abs; } + function writeDesignMd() { + fs.writeFileSync(path.join(cwd, 'DESIGN.md'), `--- +typography: + body: + fontFamily: "IBM Plex Sans, Arial, sans-serif" +colors: + ink: "#241f1a" +rounded: + md: "8px" +--- + +# Design System +`); + } + + function designFinding(value = 'Poppins') { + return { + ...finding('design-system-font', 1, { + name: 'Font outside DESIGN.md', + description: 'A font is used that is not declared in DESIGN.md typography.', + snippet: `font-family: "${value}", sans-serif;`, + }), + ignoreValue: value, + }; + } + + function designAwareDetector({ stale = false } = {}) { + return { + loadDesignSystemForCwd: (projectCwd) => ( + fs.existsSync(path.join(projectCwd, 'DESIGN.md')) + ? { present: true, hasFonts: true, mdNewerThanJson: stale } + : null + ), + detectText: (_content, _filePath, options = {}) => ( + options.designSystem ? [designFinding()] : [] + ), + detectHtml: (_filePath, options = {}) => ( + options.designSystem ? [designFinding()] : [] + ), + }; + } + it('emits findings on first fire, then a pending-ack on subsequent dedup hits', async () => { // The "no silent fires" policy turns the previously-silent dedup hit // into a pending re-nudge that keeps the unresolved finding in the @@ -892,6 +1016,94 @@ describe('runHook()', () => { assert.equal(r.audit.skipped, 'config-disabled'); }); + it('only unlocks design-system detector findings when DESIGN.md exists', async () => { + const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }'); + const det = designAwareDetector(); + + const withoutDesign = await runHook({ + stdinJson: JSON.stringify(eventFor(file, 'design-system-off')), + env: {}, + cwd, + detector: det, + }); + assert.match(withoutDesign.stdout, /No anti-patterns/); + assert.doesNotMatch(withoutDesign.stdout, /design-system-font/); + + writeDesignMd(); + const withDesign = await runHook({ + stdinJson: JSON.stringify(eventFor(file, 'design-system-on')), + env: {}, + cwd, + detector: det, + }); + assert.match(withDesign.stdout, /Design hook findings requiring review/); + assert.match(withDesign.stdout, /design-system-font/); + assert.match(withDesign.stdout, /ignore-value design-system-font Poppins --shared/); + }); + + it('respects detector.designSystem.enabled=false', async () => { + writeDesignMd(); + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ + detector: { designSystem: { enabled: false } }, + })); + const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }'); + + const r = await runHook({ + stdinJson: JSON.stringify(eventFor(file, 'design-system-disabled')), + env: {}, + cwd, + detector: designAwareDetector(), + }); + + assert.match(r.stdout, /No anti-patterns/); + assert.doesNotMatch(r.stdout, /design-system-font/); + }); + + it('suppresses design-system findings through ignore-value', async () => { + writeDesignMd(); + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ + detector: { + ignoreValues: [ + { rule: 'design-system-font', value: 'Poppins' }, + ], + }, + })); + const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }'); + + const r = await runHook({ + stdinJson: JSON.stringify(eventFor(file, 'design-system-ignore-value')), + env: {}, + cwd, + detector: designAwareDetector(), + }); + + assert.match(r.stdout, /No anti-patterns/); + assert.doesNotMatch(r.stdout, /design-system-font/); + }); + + it('adds a non-blocking note when DESIGN.md is newer than the sidecar', async () => { + writeDesignMd(); + const file = writeFixture('src/Card.tsx', 'noop'); + const det = { + loadDesignSystemForCwd: () => ({ present: true, mdNewerThanJson: true }), + detectText: () => [], + detectHtml: () => [], + }; + + const r = await runHook({ + stdinJson: JSON.stringify(eventFor(file, 'design-system-stale-sidecar')), + env: {}, + cwd, + detector: det, + }); + + assert.match(r.stdout, /No anti-patterns/); + assert.match(r.stdout, /DESIGN\.md is newer than \.impeccable\/design\.json/); + assert.match(r.stdout, /\/impeccable document/); + }); + it('rejects sensitive paths before reading file content', async () => { const file = path.join(cwd, '.env'); fs.writeFileSync(file, 'SECRET=42'); @@ -927,7 +1139,7 @@ describe('runHook()', () => { const file = writeFixture('src/legacy/Foo.tsx', 'noop'); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ - hook: { ignoreFiles: ['src/legacy/**'] }, + detector: { ignoreFiles: ['src/legacy/**'] }, })); const det = fakeDetector([finding('side-tab', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); diff --git a/tests/lib/impeccable-config.test.js b/tests/lib/impeccable-config.test.js index 0b5cce08f..d55d6e96d 100644 --- a/tests/lib/impeccable-config.test.js +++ b/tests/lib/impeccable-config.test.js @@ -5,11 +5,17 @@ import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { + extractFindingIgnoreValue, + filterDetectionFindings, getHookConsent, setHookConsent, getLocalConfigPath, getConfigPath, ensureConfigGitExclude, + readDetectionConfig, + readRawDetectionConfig, + shouldIgnoreDetectionFile, + writeDetectionConfig, } from '../../cli/lib/impeccable-config.mjs'; describe('cli/lib/impeccable-config', () => { @@ -64,4 +70,160 @@ describe('cli/lib/impeccable-config', () => { test('ensureConfigGitExclude is a no-op outside a git repo', () => { expect(ensureConfigGitExclude(root)).toBe(false); }); + + test('readDetectionConfig merges shared and local detector filters', () => { + mkdirSync(join(root, '.impeccable'), { recursive: true }); + writeFileSync(getConfigPath(root), JSON.stringify({ + detector: { + ignoreRules: ['side-tab'], + ignoreFiles: ['src/legacy/**'], + ignoreValues: [ + { rule: 'overused-font', value: 'Avenir Next', reason: 'team default' }, + { rule: 'design-system-color', value: '*', files: ['src/demo.css'] }, + ], + designSystem: { enabled: false }, + }, + })); + writeFileSync(getLocalConfigPath(root), JSON.stringify({ + detector: { + ignoreRules: ['gradient-text'], + ignoreFiles: ['src/local/**'], + ignoreValues: [ + { rule: 'overused-font', value: 'Avenir Next', reason: 'local override' }, + { rule: 'bounce-easing', value: 'bounce-ball' }, + ], + designSystem: { enabled: true }, + }, + })); + + const cfg = readDetectionConfig(root); + expect(cfg.ignoreRules).toEqual(['side-tab', 'gradient-text']); + expect(cfg.ignoreFiles).toEqual(['src/legacy/**', 'src/local/**']); + expect(cfg.ignoreValues).toEqual([ + { rule: 'overused-font', value: 'avenir next', reason: 'local override' }, + { rule: 'design-system-color', value: '*', files: ['src/demo.css'] }, + { rule: 'bounce-easing', value: 'bounce-ball' }, + ]); + expect(cfg.designSystem).toEqual({ enabled: true }); + }); + + test('readDetectionConfig remains backward-compatible with legacy hook filters', () => { + mkdirSync(join(root, '.impeccable'), { recursive: true }); + writeFileSync(getConfigPath(root), JSON.stringify({ + hook: { + ignoreRules: ['side-tab'], + ignoreFiles: ['src/legacy/**'], + ignoreValues: [{ rule: 'overused-font', value: 'Avenir Next' }], + designSystem: { enabled: false }, + }, + })); + const cfg = readDetectionConfig(root); + expect(cfg.ignoreRules).toEqual(['side-tab']); + expect(cfg.ignoreFiles).toEqual(['src/legacy/**']); + expect(cfg.ignoreValues).toEqual([{ rule: 'overused-font', value: 'avenir next' }]); + expect(cfg.designSystem).toEqual({ enabled: false }); + }); + + test('writeDetectionConfig writes detector config and strips legacy hook filters', () => { + mkdirSync(join(root, '.impeccable'), { recursive: true }); + writeFileSync(getConfigPath(root), JSON.stringify({ + updateCheck: false, + hook: { + consent: 'accepted', + quiet: true, + ignoreRules: ['legacy-rule'], + ignoreFiles: ['legacy/**'], + ignoreValues: [{ rule: 'overused-font', value: 'Legacy' }], + }, + })); + + const config = readRawDetectionConfig(root); + config.ignoreRules.push('side-tab'); + config.ignoreFiles.push('src/legacy/**'); + writeDetectionConfig(root, config); + + const raw = JSON.parse(readFileSync(getConfigPath(root), 'utf-8')); + expect(raw.updateCheck).toBe(false); + expect(raw.hook).toEqual({ consent: 'accepted', quiet: true }); + expect(raw.detector.ignoreRules).toEqual(['legacy-rule', 'side-tab']); + expect(raw.detector.ignoreFiles).toEqual(['legacy/**', 'src/legacy/**']); + expect(raw.detector.ignoreValues).toEqual([{ rule: 'overused-font', value: 'legacy' }]); + expect(raw.detector.designSystem).toBeUndefined(); + }); + + test('writeDetectionConfig local ignores do not create an implicit design-system override', () => { + execFileSync('git', ['init', '-q'], { cwd: root }); + mkdirSync(join(root, '.impeccable'), { recursive: true }); + writeFileSync(getConfigPath(root), JSON.stringify({ + detector: { designSystem: { enabled: false } }, + })); + + const local = readRawDetectionConfig(root, { local: true }); + local.ignoreValues.push({ rule: 'overused-font', value: 'Inter' }); + writeDetectionConfig(root, local, { local: true }); + + const rawLocal = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8')); + expect(rawLocal.detector.designSystem).toBeUndefined(); + expect(readDetectionConfig(root).designSystem).toEqual({ enabled: false }); + expect(readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8')).toContain('.impeccable/config.local.json'); + }); + + test('shouldIgnoreDetectionFile matches relative and absolute paths', () => { + const cfg = { ignoreFiles: ['src/legacy/**', '*.generated.tsx'] }; + expect(shouldIgnoreDetectionFile(join(root, 'src', 'legacy', 'Card.tsx'), root, cfg)).toBe(true); + expect(shouldIgnoreDetectionFile(join(root, 'src', 'Card.generated.tsx'), root, cfg)).toBe(true); + expect(shouldIgnoreDetectionFile(join(root, 'src', 'Card.tsx'), root, cfg)).toBe(false); + }); + + test('filterDetectionFindings matches hook ignore value semantics', () => { + const findings = [ + { antipattern: 'overused-font', file: join(root, 'src', 'main.css'), line: 1, snippet: 'Primary font: Avenir Next' }, + { antipattern: 'overused-font', file: join(root, 'src', 'other.css'), line: 2, snippet: 'Primary font: Karla' }, + { antipattern: 'design-system-color', file: join(root, 'src', 'demo.css'), line: 3, ignoreValue: '#8b5cf6' }, + { antipattern: 'design-system-color', file: join(root, 'src', 'real.css'), line: 4, ignoreValue: '#8b5cf6' }, + { antipattern: 'design-system-font', file: join(root, 'src', 'demo.css'), line: 5, ignoreValue: 'Avenir Next' }, + ]; + const filtered = filterDetectionFindings(findings, { + ignoreRules: [], + ignoreValues: [ + { rule: 'overused-font', value: 'avenir next' }, + { rule: 'design-system-color', value: '*', files: ['src/demo.css'] }, + { rule: 'design-system-font', value: '*' }, + ], + }); + + expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual([ + 'overused-font:2', + 'design-system-color:4', + 'design-system-font:5', + ]); + }); + + test('filterDetectionFindings matches equivalent design-system color values', () => { + const findings = [ + { antipattern: 'design-system-color', file: join(root, 'src', 'rgb.css'), line: 1, ignoreValue: 'rgb(139, 92, 246)' }, + { antipattern: 'design-system-color', file: join(root, 'src', 'hex.css'), line: 2, ignoreValue: '#8b5cf6' }, + { antipattern: 'design-system-color', file: join(root, 'src', 'alpha.css'), line: 3, ignoreValue: 'rgba(139, 92, 246, 0.5)' }, + { antipattern: 'design-system-color', file: join(root, 'src', 'other.css'), line: 4, ignoreValue: '#8b5cf7' }, + { antipattern: 'design-system-radius', file: join(root, 'src', 'radius.css'), line: 5, ignoreValue: 'rgb(139, 92, 246)' }, + ]; + const filtered = filterDetectionFindings(findings, { + ignoreValues: [ + { rule: 'design-system-color', value: '#8b5cf6' }, + { rule: 'design-system-color', value: 'rgb(139 92 246 / 100%)' }, + ], + }); + + expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual([ + 'design-system-color:3', + 'design-system-color:4', + 'design-system-radius:5', + ]); + }); + + test('extractFindingIgnoreValue handles fonts, Google font URLs, and motion snippets', () => { + expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'Primary font: Avenir Next (80% of text)' })).toBe('avenir next'); + expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'https://fonts.googleapis.com/css2?family=Alumni+Sans:wght@700' })).toBe('alumni sans'); + expect(extractFindingIgnoreValue({ antipattern: 'bounce-easing', snippet: 'animation: bounce-ball 1s infinite' })).toBe('bounce-ball'); + }); }); diff --git a/tests/shiki-theme.test.mjs b/tests/shiki-theme.test.mjs new file mode 100644 index 000000000..4b2a3de2b --- /dev/null +++ b/tests/shiki-theme.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { loadDesignSystemForCwd, isAllowedColorRaw } from '../cli/engine/design-system.mjs'; +import { parseAnyColor } from '../cli/engine/rules/checks.mjs'; +import { impeccableShikiThemes } from '../site/lib/impeccable-shiki-theme.mjs'; + +function relativeLuminance({ r, g, b }) { + const channel = value => { + const s = value / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return (0.2126 * channel(r)) + (0.7152 * channel(g)) + (0.0722 * channel(b)); +} + +function contrastRatio(a, b) { + const lighter = Math.max(relativeLuminance(a), relativeLuminance(b)); + const darker = Math.min(relativeLuminance(a), relativeLuminance(b)); + return (lighter + 0.05) / (darker + 0.05); +} + +function themeForegrounds(theme) { + const colors = new Set([theme.colors['editor.foreground']]); + for (const token of theme.tokenColors || []) { + if (token.settings?.foreground) colors.add(token.settings.foreground); + } + return [...colors]; +} + +describe('impeccable Shiki themes', () => { + it('only use colors from DESIGN.md and the sidecar ramps', () => { + const designSystem = loadDesignSystemForCwd(process.cwd()); + assert.ok(designSystem?.present, 'DESIGN.md should be available for docs theme validation'); + + for (const [mode, theme] of Object.entries(impeccableShikiThemes)) { + const colors = new Set([ + theme.colors['editor.background'], + theme.colors['editor.foreground'], + ...themeForegrounds(theme), + ]); + + for (const color of colors) { + assert.ok(isAllowedColorRaw(color, designSystem), `${mode} Shiki color ${color} is not in DESIGN.md`); + } + } + }); + + it('keeps every token foreground at AA contrast against its code block background', () => { + for (const [mode, theme] of Object.entries(impeccableShikiThemes)) { + const background = parseAnyColor(theme.colors['editor.background']); + assert.ok(background, `${mode} code background should parse`); + + for (const foregroundValue of themeForegrounds(theme)) { + const foreground = parseAnyColor(foregroundValue); + assert.ok(foreground, `${mode} foreground ${foregroundValue} should parse`); + + const ratio = contrastRatio(foreground, background); + assert.ok( + ratio >= 4.5, + `${mode} foreground ${foregroundValue} only has ${ratio.toFixed(2)}:1 contrast`, + ); + } + } + }); +});