mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 09:06:53 +03:00
[codex] Add design-aware detector rules (#252)
* Add design-aware detector rules * Fix design-aware detector noise * Unify CLI and hook detector ignores * Fix remaining design-system review findings * Add detector ignore CLI * Fix design detector review findings * Fix design color source false positives * Fix core test suite registration * Add design-aware detector docs * Fix font priority design-system parsing * Fix color ignore value matching
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+65
-4
@@ -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
|
||||
|
||||
+225
-59
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Impeccable CLI
|
||||
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable detect [file-or-dir-or-url...]
|
||||
* npx impeccable ignores <list|add-file|add-value|remove-...>
|
||||
* 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));
|
||||
|
||||
@@ -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 <action> [options]
|
||||
|
||||
Manage detector ignores in .impeccable config.
|
||||
|
||||
Actions:
|
||||
list Show merged, shared, and local ignores
|
||||
add-rule <rule> [--all-values] Ignore a rule
|
||||
add-file <glob> Ignore files by glob
|
||||
add-value <rule> <value> Ignore one rule/value pair
|
||||
remove-rule <rule> Remove a rule ignore
|
||||
remove-file <glob> Remove a file ignore
|
||||
remove-value <rule> <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 <glob> Scope add-value/remove-value to a file glob
|
||||
--reason <text> 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 <glob>.');
|
||||
}
|
||||
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 <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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+31
-7
@@ -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');
|
||||
|
||||
@@ -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('<!--');
|
||||
}
|
||||
|
||||
function isProbablyColorLiteral(line, match) {
|
||||
const raw = match?.[0] || '';
|
||||
const index = match.index ?? -1;
|
||||
if (index < 0) return false;
|
||||
if (isInsideCssAttributeSelector(line, index)) return false;
|
||||
|
||||
const before = line.slice(0, index);
|
||||
const after = line.slice(index + raw.length);
|
||||
|
||||
if (raw.startsWith('#')) {
|
||||
if (before.endsWith('&')) return false; // HTML numeric entity, e.g. ↔
|
||||
|
||||
const prevNonSpace = before.match(/\S(?=\s*$)/)?.[0] || '';
|
||||
const nextNonSpace = after.match(/^\s*(\S)/)?.[1] || '';
|
||||
if (prevNonSpace === '>' && nextNonSpace === '<') return false; // plain text, e.g. PR #155
|
||||
}
|
||||
|
||||
const styleContext = /(?:^|[{\s;"'`(,])(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke)\s*:\s*[^;{}"'`]*/i.test(before);
|
||||
const cssFunctionContext = /(?:linear-gradient|radial-gradient|conic-gradient|color-mix)\([^)]*$/i.test(before);
|
||||
const jsColorKeyContext = /(?:^|[,{]\s*)(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke|boxShadow|textShadow)\s*[:=]\s*["'`]?[^"'`,}]*/i.test(before);
|
||||
|
||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||
}
|
||||
|
||||
function isInsideCssAttributeSelector(line, index) {
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
const lastOpen = before.lastIndexOf('[');
|
||||
if (lastOpen === -1) return false;
|
||||
const lastClose = before.lastIndexOf(']');
|
||||
if (lastClose > lastOpen) return false;
|
||||
const after = line.slice(index);
|
||||
const close = after.indexOf(']');
|
||||
const block = after.indexOf('{');
|
||||
return close !== -1 && (block === -1 || close < block);
|
||||
}
|
||||
|
||||
function makeDesignFinding(id, filePath, snippet, line = 0, extras = {}) {
|
||||
return { ...finding(id, filePath, snippet, line), ...extras };
|
||||
}
|
||||
|
||||
function decodeGoogleFamily(value) {
|
||||
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
|
||||
try {
|
||||
return decodeURIComponent(family);
|
||||
} catch {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
function checkFontStack(stack, filePath, line, designSystem, context) {
|
||||
const primary = primaryFont(stack);
|
||||
if (!primary || isAllowedFont(primary, designSystem)) return [];
|
||||
const display = primary.replace(/\b\w/g, ch => ch.toUpperCase());
|
||||
return [makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`${context}: ${display} is not declared in DESIGN.md typography`,
|
||||
line,
|
||||
{ ignoreValue: display },
|
||||
)];
|
||||
}
|
||||
|
||||
function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function checkRadiusValue(value, filePath, line, designSystem, context) {
|
||||
const findings = [];
|
||||
for (const token of extractRadiusTokens(value)) {
|
||||
if (isAllowedRadiusRaw(token, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-radius',
|
||||
filePath,
|
||||
`${context}: ${token} is outside the DESIGN.md rounded scale`,
|
||||
line,
|
||||
{ ignoreValue: token },
|
||||
));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
const designSystem = options.designSystem;
|
||||
if (!designSystem?.present) return [];
|
||||
|
||||
const findings = [];
|
||||
const lines = String(content || '').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
if (lineLooksCommented(line)) continue;
|
||||
|
||||
if (designSystem.hasFonts) {
|
||||
for (const match of line.matchAll(FONT_DECL_RE)) {
|
||||
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'font-family'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_JS_RE)) {
|
||||
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'fontFamily'));
|
||||
}
|
||||
for (const match of line.matchAll(GOOGLE_FONT_RE)) {
|
||||
const url = match[0];
|
||||
for (const familyMatch of url.matchAll(/[?&]family=([^&]+)/g)) {
|
||||
const font = normalizeFontName(decodeGoogleFamily(familyMatch[1]));
|
||||
if (!font || isAllowedFont(font, designSystem)) continue;
|
||||
const display = decodeGoogleFamily(familyMatch[1]);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`Google Fonts: ${display} is not declared in DESIGN.md typography`,
|
||||
lineNum,
|
||||
{ ignoreValue: display },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasColors) {
|
||||
for (const match of line.matchAll(CSS_COLOR_RE)) {
|
||||
if (!isProbablyColorLiteral(line, match)) continue;
|
||||
const raw = cssColorLabel(match[0]);
|
||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
`Undocumented color ${raw} is outside DESIGN.md colors`,
|
||||
lineNum,
|
||||
{ ignoreValue: raw },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasRadii) {
|
||||
for (const match of line.matchAll(BORDER_RADIUS_RE)) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'border-radius'));
|
||||
}
|
||||
for (const match of line.matchAll(BORDER_RADIUS_JS_RE)) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeDesignFindings(findings);
|
||||
}
|
||||
|
||||
function hasDirectText(el) {
|
||||
return Array.from(el.childNodes || []).some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
|
||||
}
|
||||
|
||||
function sampleText(el) {
|
||||
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
return text ? ` "${text.slice(0, 40)}"` : '';
|
||||
}
|
||||
|
||||
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
|
||||
if (!designSystem?.present) return [];
|
||||
const findings = [];
|
||||
const seenFonts = new Set();
|
||||
const seenColors = new Set();
|
||||
const seenRadii = new Set();
|
||||
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
if (shouldSkipStaticDesignElement(el, window)) continue;
|
||||
const tag = el.tagName?.toLowerCase?.() || 'unknown';
|
||||
const style = window.getComputedStyle(el);
|
||||
|
||||
if (designSystem.hasFonts && hasDirectText(el)) {
|
||||
const font = primaryFont(style.fontFamily || '');
|
||||
if (font && !seenFonts.has(font) && !isAllowedFont(font, designSystem)) {
|
||||
seenFonts.add(font);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`${tag}${sampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
|
||||
0,
|
||||
{ ignoreValue: font },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasColors) {
|
||||
const colorChecks = [];
|
||||
if (hasDirectText(el)) colorChecks.push(['text color', style.color]);
|
||||
if (!isTransparentCss(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 = cssColorLabel(raw);
|
||||
if (isAllowedColorRaw(label, designSystem)) continue;
|
||||
const key = `${kind}:${label}`;
|
||||
if (seenColors.has(key)) continue;
|
||||
seenColors.add(key);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
`${kind} ${label} on ${tag}${sampleText(el)} is outside DESIGN.md colors`,
|
||||
0,
|
||||
{ ignoreValue: label },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasRadii) {
|
||||
const rawRadius = String(style.borderRadius || '').trim();
|
||||
if (!rawRadius) continue;
|
||||
for (const token of extractRadiusTokens(rawRadius)) {
|
||||
if (isAllowedRadiusRaw(token, designSystem)) continue;
|
||||
if (seenRadii.has(token)) continue;
|
||||
seenRadii.add(token);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-radius',
|
||||
filePath,
|
||||
`border-radius ${token} on ${tag}${sampleText(el)} is outside the DESIGN.md rounded scale`,
|
||||
0,
|
||||
{ ignoreValue: token },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function shouldSkipStaticDesignElement(el, window) {
|
||||
const tag = el.tagName?.toLowerCase?.() || '';
|
||||
if (STATIC_DESIGN_SKIP_TAGS.has(tag)) return true;
|
||||
|
||||
let current = el;
|
||||
while (current) {
|
||||
if (current.getAttribute?.('hidden') !== null || current.getAttribute?.('aria-hidden') === 'true') return true;
|
||||
const style = window.getComputedStyle(current);
|
||||
const display = String(style.display || '').toLowerCase();
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse') return true;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTransparentCss(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text || text === 'transparent') return true;
|
||||
const parsed = parseDesignColor(text);
|
||||
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
|
||||
}
|
||||
|
||||
function canonicalDesignFindingKey(item) {
|
||||
if (!item?.antipattern?.startsWith?.('design-system-')) return null;
|
||||
const value = item.ignoreValue || item.value || '';
|
||||
if (item.antipattern === 'design-system-font') {
|
||||
const context = /google fonts/i.test(item.snippet || '') ? 'google-font' : 'font';
|
||||
const font = normalizeFontName(value);
|
||||
return font ? `${item.antipattern}:${context}:${font}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-color') {
|
||||
const parsed = parseDesignColor(value);
|
||||
if (parsed) return `${item.antipattern}:color:${colorKey(parsed)}`;
|
||||
const label = cssColorLabel(value).toLowerCase();
|
||||
return label ? `${item.antipattern}:color:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-radius') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:radius:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:radius:${label}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeDesignSystemFindings(...groups) {
|
||||
const out = [];
|
||||
const seen = new Map();
|
||||
for (const group of groups) {
|
||||
for (const item of group || []) {
|
||||
const key = canonicalDesignFindingKey(item);
|
||||
if (key) {
|
||||
if (seen.has(key)) {
|
||||
const existing = out[seen.get(key)];
|
||||
if ((existing.line || 0) <= 0 && (item.line || 0) > 0) existing.line = item.line;
|
||||
continue;
|
||||
}
|
||||
seen.set(key, out.length);
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dedupeDesignFindings(findings) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const item of findings) {
|
||||
const key = [
|
||||
item.antipattern,
|
||||
item.line || 0,
|
||||
normalizeFontName(item.ignoreValue || item.snippet || ''),
|
||||
].join('\0');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export {
|
||||
parseFrontmatter,
|
||||
normalizeDesignSystem,
|
||||
loadDesignSystemForCwd,
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
};
|
||||
@@ -425,6 +425,35 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'overflow container clipping positioned children',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font family outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-color',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
name: 'Color outside DESIGN.md',
|
||||
description:
|
||||
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'literal color outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-radius',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
name: 'Radius outside DESIGN.md',
|
||||
description:
|
||||
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -4394,6 +4423,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 : '',
|
||||
};
|
||||
@@ -4430,10 +4460,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
|
||||
@@ -4464,6 +4687,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);
|
||||
@@ -4480,6 +4704,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);
|
||||
|
||||
@@ -23,6 +23,13 @@ export {
|
||||
checkHtmlPatterns,
|
||||
} from './rules/checks.mjs';
|
||||
export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs';
|
||||
export {
|
||||
parseFrontmatter as parseDesignFrontmatter,
|
||||
normalizeDesignSystem,
|
||||
loadDesignSystemForCwd,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
} from './design-system.mjs';
|
||||
export { detectHtml } from './engines/static-html/detect-html.mjs';
|
||||
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
|
||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||
|
||||
@@ -7,6 +7,25 @@ import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
||||
|
||||
function serializeDesignSystemForBrowser(designSystem) {
|
||||
if (!designSystem?.present) return null;
|
||||
return {
|
||||
present: true,
|
||||
hasFonts: designSystem.hasFonts === true,
|
||||
allowedFonts: Array.from(designSystem.allowedFonts || []),
|
||||
hasColors: designSystem.hasColors === true,
|
||||
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
|
||||
.map(entry => entry?.color)
|
||||
.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 })),
|
||||
hasRadii: designSystem.hasRadii === true,
|
||||
allowedRadii: (designSystem.allowedRadii || [])
|
||||
.map(entry => Number(entry?.px))
|
||||
.filter(px => Number.isFinite(px)),
|
||||
hasPillRadius: designSystem.hasPillRadius === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
|
||||
if (options?.visualContrast === false) return [];
|
||||
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
|
||||
@@ -163,17 +182,19 @@ async function detectUrl(url, options = {}) {
|
||||
}
|
||||
|
||||
// Inject the browser detection script and collect results
|
||||
const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'configure-pure-detect',
|
||||
target: url,
|
||||
}, () => page.evaluate(() => {
|
||||
}, () => page.evaluate((designSystem) => {
|
||||
window.__IMPECCABLE_CONFIG__ = {
|
||||
...(window.__IMPECCABLE_CONFIG__ || {}),
|
||||
autoScan: false,
|
||||
...(designSystem ? { designSystem } : {}),
|
||||
};
|
||||
}));
|
||||
}, browserDesignSystem));
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
@@ -192,7 +213,7 @@ async function detectUrl(url, options = {}) {
|
||||
return window.impeccableDetect({ decorate: false, serialize: true });
|
||||
});
|
||||
return serializedGroups.flatMap(({ findings }) =>
|
||||
findings.map(f => ({ id: f.type, snippet: f.detail }))
|
||||
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' }))
|
||||
);
|
||||
});
|
||||
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
|
||||
@@ -213,7 +234,11 @@ async function detectUrl(url, options = {}) {
|
||||
}, () => browser.close());
|
||||
}
|
||||
}
|
||||
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
|
||||
return filterByProviders(results.map(f => {
|
||||
const item = finding(f.id, url, f.snippet);
|
||||
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
|
||||
return item;
|
||||
}), options.providers);
|
||||
}
|
||||
|
||||
async function createBrowserDetector(options = {}) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
@@ -503,6 +504,15 @@ function detectText(content, filePath, options = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'source',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));
|
||||
}
|
||||
|
||||
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
|
||||
const deduped = [];
|
||||
for (const f of findings) {
|
||||
|
||||
@@ -272,6 +272,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginBottom: '0px',
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
@@ -326,6 +327,7 @@ const STATIC_PROP_MAP = {
|
||||
'margin-bottom': 'marginBottom',
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'visibility': 'visibility',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
|
||||
@@ -2,6 +2,11 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
@@ -168,6 +173,22 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
const sourceDesignFindings = profileFindings(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'source',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem }));
|
||||
const staticDesignFindings = profileFindings(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'page',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem));
|
||||
findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings));
|
||||
}
|
||||
|
||||
if (isFullPage(html)) {
|
||||
const runPageCheck = (ruleId, callback) => profile
|
||||
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
|
||||
|
||||
@@ -323,6 +323,35 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'overflow container clipping positioned children',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font family outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-color',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
name: 'Color outside DESIGN.md',
|
||||
description:
|
||||
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'literal color outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-radius',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
name: 'Radius outside DESIGN.md',
|
||||
description:
|
||||
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout and the
|
||||
* `.git/info/exclude` handling. Keep the schema and exclude marker in sync if
|
||||
* either side changes.
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* { "hook": { "consent": "accepted" | "declined", ... }, "updateCheck": bool }
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute } from 'node:path';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
@@ -35,6 +39,521 @@ function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
|
||||
@@ -29,7 +29,7 @@ export const SUITES = {
|
||||
/^site\/(pages|content|components|layouts)\//,
|
||||
/^README(\.npm)?\.md$/,
|
||||
/^cli\/bin\//,
|
||||
/^tests\/(build|cleanup-deprecated|context|context-signals|critique-storage|design-parser|docs-integrity|hook|hook-build|impeccable-paths|skills-cli|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/,
|
||||
/^tests\/(build|cleanup-deprecated|cli-ignores|context|context-signals|critique-storage|design-parser|docs-integrity|hook|hook-build|impeccable-paths|shiki-theme|skills-cli|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/,
|
||||
/^tests\/lib\//,
|
||||
],
|
||||
commands: [
|
||||
@@ -37,6 +37,7 @@ export const SUITES = {
|
||||
runner: 'bun',
|
||||
files: [
|
||||
'tests/build.test.js',
|
||||
'tests/cli-ignores.test.js',
|
||||
'tests/windows-path-fix.test.js',
|
||||
'tests/lib/provider-blocks.test.js',
|
||||
'tests/lib/transformers/provider-blocks.test.js',
|
||||
@@ -59,6 +60,7 @@ export const SUITES = {
|
||||
'tests/hook-build.test.mjs',
|
||||
'tests/hook.test.mjs',
|
||||
'tests/impeccable-paths.test.mjs',
|
||||
'tests/shiki-theme.test.mjs',
|
||||
'tests/test-suites.test.mjs',
|
||||
'tests/zip.test.mjs',
|
||||
],
|
||||
@@ -74,6 +76,7 @@ export const SUITES = {
|
||||
/^extension\/(background|content|detector|devtools|popup|manifest\.json)/,
|
||||
/^scripts\/(benchmark-detector|build-browser-detector|build-extension)\.js$/,
|
||||
/^site\/(pages\/detector|public\/antipattern|data\/anti-patterns-catalog\.js)/,
|
||||
/^tests\/design-system\.test\.mjs$/,
|
||||
/^tests\/(detect-antipatterns|extension-build|fixtures\/antipatterns)/,
|
||||
],
|
||||
commands: [
|
||||
@@ -88,6 +91,7 @@ export const SUITES = {
|
||||
runner: 'node',
|
||||
files: [
|
||||
'tests/extension-build.test.mjs',
|
||||
'tests/design-system.test.mjs',
|
||||
'tests/detect-antipatterns-fixtures.test.mjs',
|
||||
'tests/detect-antipatterns-browser.test.mjs',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
// Shared sidebar for the /docs section.
|
||||
// Renders Tutorials + Commands, highlighting the current entry.
|
||||
// Renders Tutorials + Reference + Commands, highlighting the current entry.
|
||||
// Used by:
|
||||
// - site/pages/docs/index.astro (no active entry)
|
||||
// - site/layouts/Doc.astro (activeCommand)
|
||||
@@ -14,13 +14,22 @@ import {
|
||||
|
||||
interface Props {
|
||||
activeCommand?: string;
|
||||
activeReference?: string;
|
||||
activeTutorial?: string;
|
||||
}
|
||||
const { activeCommand, activeTutorial } = Astro.props;
|
||||
const { activeCommand, activeReference, activeTutorial } = Astro.props;
|
||||
|
||||
const tutorials = (await getCollection('tutorials'))
|
||||
.sort((a, b) => a.data.order - b.data.order);
|
||||
|
||||
const reference = (await getCollection('reference'))
|
||||
.sort((a, b) => a.data.order - b.data.order);
|
||||
const referenceGroups = [
|
||||
{ section: 'concepts', label: 'Core concepts' },
|
||||
{ section: 'automation', label: 'Automation' },
|
||||
{ section: 'reference', label: 'Reference' },
|
||||
];
|
||||
|
||||
const skills = await getCollection('skills');
|
||||
const sidebarGroups: Record<string, { slug: string }[]> = {};
|
||||
for (const cat of CATEGORY_ORDER) {
|
||||
@@ -33,7 +42,7 @@ for (const cat of CATEGORY_ORDER) {
|
||||
|
||||
<aside class="skills-sidebar" aria-label="Docs navigation">
|
||||
<button class="skills-sidebar-toggle" type="button" aria-expanded="false" aria-controls="skills-sidebar-inner">
|
||||
<span class="skills-sidebar-toggle-label">Docs</span>
|
||||
<span class="skills-sidebar-toggle-label">Browse docs</span>
|
||||
<svg class="skills-sidebar-toggle-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div class="skills-sidebar-inner" id="skills-sidebar-inner">
|
||||
@@ -55,6 +64,29 @@ for (const cat of CATEGORY_ORDER) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{reference.length > 0 && (
|
||||
<>
|
||||
<p class="skills-sidebar-label">Reference</p>
|
||||
{referenceGroups.map(group => {
|
||||
const entries = reference.filter(entry => entry.data.section === group.section);
|
||||
return entries.length > 0 && (
|
||||
<div class="skills-sidebar-group">
|
||||
<span class="skills-sidebar-category">{group.label}</span>
|
||||
<ul class="skills-sidebar-list">
|
||||
{entries.map(entry => (
|
||||
<li>
|
||||
<a href={`/docs/${entry.id}`} aria-current={entry.id === activeReference ? 'page' : undefined}>
|
||||
<span>{entry.data.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p class="skills-sidebar-label">Commands</p>
|
||||
{CATEGORY_ORDER.map(cat => (
|
||||
<div class="skills-sidebar-group">
|
||||
|
||||
+12
-1
@@ -18,4 +18,15 @@ const tutorials = defineCollection({
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { skills, tutorials };
|
||||
const reference = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './site/content/reference' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
tagline: z.string(),
|
||||
description: z.string(),
|
||||
section: z.enum(['concepts', 'automation', 'reference']),
|
||||
order: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { skills, tutorials, reference };
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
title: Config and ignores
|
||||
tagline: "Project settings for detector exceptions, hook behavior, and local overrides."
|
||||
description: "Use .impeccable config for confirmed detector exceptions and runtime settings. Keep product and design intent in PRODUCT.md and DESIGN.md."
|
||||
section: concepts
|
||||
order: 2
|
||||
---
|
||||
|
||||
Impeccable stores runtime settings under `.impeccable/`. Most users do not need to hand-edit those files. Use the CLI when you want to record a confirmed exception.
|
||||
|
||||
Use config for:
|
||||
|
||||
- detector ignores shared by `npx impeccable detect` and the design hook;
|
||||
- private local ignores that should not be committed;
|
||||
- hook lifecycle settings such as enabled, quiet mode, and audit logging.
|
||||
|
||||
Use `PRODUCT.md` and `DESIGN.md` for product and design intent. See [Design Context](/docs/context).
|
||||
|
||||
## The usual path
|
||||
|
||||
List the current ignores:
|
||||
|
||||
```bash
|
||||
npx impeccable ignores list
|
||||
```
|
||||
|
||||
Add the narrowest exception that matches the real reason:
|
||||
|
||||
```bash
|
||||
npx impeccable ignores add-value design-system-color "#ff00aa" --reason "Campaign accent"
|
||||
npx impeccable ignores add-file "src/legacy/**"
|
||||
npx impeccable ignores add-rule side-tab
|
||||
```
|
||||
|
||||
Remove an exception when the underlying code is fixed:
|
||||
|
||||
```bash
|
||||
npx impeccable ignores remove-value design-system-color "#ff00aa"
|
||||
```
|
||||
|
||||
The same detector config is used by the CLI and the hook, so an ignore behaves consistently in both places.
|
||||
|
||||
## Shared or local
|
||||
|
||||
Default ignores go into `.impeccable/config.json`. Commit them when they represent team intent: a legacy folder, a confirmed brand exception, or a project-wide rule decision.
|
||||
|
||||
Use `--local` for private work:
|
||||
|
||||
```bash
|
||||
npx impeccable ignores add-file "src/private-experiment/**" --local
|
||||
```
|
||||
|
||||
Local settings go into `.impeccable/config.local.json`, which Impeccable keeps out of git.
|
||||
|
||||
## Value ignores
|
||||
|
||||
Prefer value ignores when a rule reports a specific value:
|
||||
|
||||
```bash
|
||||
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
```
|
||||
|
||||
Fonts, colors, radii, and motion values should usually be suppressed by value, not by whole rule. That keeps the rule useful everywhere else.
|
||||
|
||||
Wildcard value ignores are allowed only when scoped to a file:
|
||||
|
||||
```bash
|
||||
npx impeccable ignores add-value design-system-color "*" --file "src/demo.css"
|
||||
```
|
||||
|
||||
That keeps one intentionally experimental file from teaching the whole project that every undocumented color is acceptable.
|
||||
|
||||
## Details when the default path is not enough
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>What the config file looks like</summary>
|
||||
<div>
|
||||
<p>The shared config lives at <code>.impeccable/config.json</code>. A typical file looks like this:</p>
|
||||
<pre><code>{
|
||||
"detector": {
|
||||
"ignoreRules": [],
|
||||
"ignoreFiles": [],
|
||||
"ignoreValues": [],
|
||||
"designSystem": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"hook": {
|
||||
"enabled": true,
|
||||
"quiet": false,
|
||||
"auditLog": ".impeccable/hook.ndjson"
|
||||
}
|
||||
}</code></pre>
|
||||
<p>The <code>detector</code> section is shared by manual scans and hooks. The <code>hook</code> section only controls automatic hook execution and hook output.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Disable design-system checks</summary>
|
||||
<div>
|
||||
<p>Design-aware rules run when <code>DESIGN.md</code> exists. Disable them for the project only when the design file is intentionally not authoritative yet:</p>
|
||||
<pre><code>{
|
||||
"detector": {
|
||||
"designSystem": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
<p>For one manual run, keep config but skip the design-system rules:</p>
|
||||
<pre><code>npx impeccable detect --no-design-system src/</code></pre>
|
||||
<p>Use <code>--no-config</code> only when you want a raw scan with no project ignores and no <code>DESIGN.md</code> context.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Hook runtime settings</summary>
|
||||
<div>
|
||||
<p>Use <code>/impeccable hooks</code> for normal lifecycle changes:</p>
|
||||
<pre><code>/impeccable hooks status
|
||||
/impeccable hooks on
|
||||
/impeccable hooks off</code></pre>
|
||||
<p><code>hook.quiet: true</code> suppresses clean and pending acknowledgements while still surfacing new findings.</p>
|
||||
<p><code>hook.auditLog</code> writes one NDJSON line per hook invocation for debugging. Leave it off during normal work.</p>
|
||||
<p>Environment variables still override config for one shell: <code>IMPECCABLE_HOOK_DISABLED</code>, <code>IMPECCABLE_HOOK_QUIET</code>, and <code>IMPECCABLE_HOOK_LOG</code>.</p>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: Design Context
|
||||
tagline: "Give Impeccable enough project memory to make specific design decisions."
|
||||
description: "Understand why Impeccable needs design context, what to put in PRODUCT.md and DESIGN.md, and how to keep that context current."
|
||||
section: concepts
|
||||
order: 1
|
||||
---
|
||||
|
||||
Impeccable works best when it can read the same product and design decisions you would give a human designer. Without that context, it has to infer audience, tone, palette, type, and component rules from code alone. That usually produces safer, more generic answers.
|
||||
|
||||
<p class="docs-context-note">If Impeccable gives you generic advice, the design context is usually missing, too vague, or stale.</p>
|
||||
|
||||
## The fast path
|
||||
|
||||
Run the setup once from your project root:
|
||||
|
||||
```text
|
||||
/impeccable init
|
||||
```
|
||||
|
||||
That creates `PRODUCT.md`, the strategy file. At the end, say yes when Impeccable offers to run:
|
||||
|
||||
```text
|
||||
/impeccable document
|
||||
```
|
||||
|
||||
That creates `DESIGN.md`, the visual-system file, plus a generated helper at `.impeccable/design.json`. Review the two markdown files. Edit anything that does not match the real product.
|
||||
|
||||
<div class="docs-context-flow" aria-label="How Impeccable uses design context">
|
||||
<div class="docs-context-flow-source">
|
||||
<span class="docs-context-flow-label">Strategy</span>
|
||||
<strong>PRODUCT.md</strong>
|
||||
<span>Audience, purpose, voice, register, anti-references.</span>
|
||||
</div>
|
||||
<div class="docs-context-flow-source">
|
||||
<span class="docs-context-flow-label">Visual system</span>
|
||||
<strong>DESIGN.md</strong>
|
||||
<span>Colors, type, components, radii, design rules.</span>
|
||||
</div>
|
||||
<div class="docs-context-flow-source docs-context-flow-source--generated">
|
||||
<span class="docs-context-flow-label">Generated</span>
|
||||
<strong>.impeccable/design.json</strong>
|
||||
<span>Structured metadata for automation. Do not hand-edit.</span>
|
||||
</div>
|
||||
<div class="docs-context-flow-output">
|
||||
<span class="docs-context-flow-label">Used by</span>
|
||||
<strong>Commands, hooks, detector, Live Mode</strong>
|
||||
<span>More specific edits, better audits, fewer false assumptions.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## What goes where
|
||||
|
||||
| File | What it should answer | Update it when |
|
||||
|---|---|---|
|
||||
| `PRODUCT.md` | Who is this for? What is the product trying to do? What should the brand feel like? Is this a brand surface or a product surface? What should the work avoid? | Audience, positioning, product purpose, voice, register, or anti-references change. |
|
||||
| `DESIGN.md` | What colors, type stacks, component treatments, radii, elevation, and visual rules are allowed? | Palette, typography, components, tokens, spacing/radius scales, or design rules change. |
|
||||
| `.impeccable/design.json` | What structured design data should automation use? | Do not edit it directly. Refresh it by running `/impeccable document`. |
|
||||
|
||||
The markdown files are the files you own. The generated JSON helps the detector, hooks, and Live Mode read the design system precisely.
|
||||
|
||||
## The most important choice: brand or product
|
||||
|
||||
Impeccable calls this choice the **register**. In daily use, just decide what kind of surface you are asking it to judge.
|
||||
|
||||
- **Brand surface:** marketing site, landing page, campaign, portfolio, editorial page. The visitor is evaluating, trusting, remembering, comparing, or feeling the brand.
|
||||
- **Product surface:** app UI, dashboard, admin screen, workflow tool, settings page. The user is configuring, monitoring, searching, submitting, comparing data, or finishing a task.
|
||||
|
||||
The same visual move can be right in one register and wrong in the other. A campaign page can afford a huge image, expressive type, and one dominant idea per screen. A dashboard needs density, predictable controls, readable states, stable navigation, and quieter motion.
|
||||
|
||||
Many codebases have both. Set the project default to the surface you work on most, then be explicit when a task differs:
|
||||
|
||||
```text
|
||||
/impeccable polish the marketing homepage as a brand surface
|
||||
/impeccable audit the billing settings as a product surface
|
||||
```
|
||||
|
||||
## How context changes the output
|
||||
|
||||
With context loaded, Impeccable can:
|
||||
|
||||
- preserve the right identity instead of "improving" it into something generic;
|
||||
- pick the right standard for the surface: expressive brand page or efficient product UI;
|
||||
- replace hardcoded visual choices with documented tokens and components;
|
||||
- flag drift, such as fonts, colors, or border radii outside `DESIGN.md`;
|
||||
- keep Live Mode variants aligned with the system instead of inventing new palettes.
|
||||
|
||||
The context does not replace judgment. Existing code still matters, and an intentional exception can be documented with a detector ignore. See [Config and ignores](/docs/config).
|
||||
|
||||
## Keeping context fresh
|
||||
|
||||
Use this rule:
|
||||
|
||||
| Change in the project | Run |
|
||||
|---|---|
|
||||
| New audience, positioning, product purpose, brand voice, or register | `/impeccable init` |
|
||||
| New palette, type stack, component primitives, radius scale, or design rules | `/impeccable document` |
|
||||
| A hook says `DESIGN.md` is newer than `.impeccable/design.json` | `/impeccable document` |
|
||||
| One-off intentional detector finding | Add a narrow ignore with `/impeccable hooks ignore-value` or `npx impeccable ignores`. |
|
||||
|
||||
Treat context files like any other design artifact: review them in code review when they change, and update them when the product changes.
|
||||
|
||||
## Details when the default path is not enough
|
||||
|
||||
<details class="docs-context-details">
|
||||
<summary>Where Impeccable looks for context files</summary>
|
||||
<div>
|
||||
<p>For normal projects, put <code>PRODUCT.md</code> and <code>DESIGN.md</code> in the project root.</p>
|
||||
<p>Skill commands look in the root first. If root context is missing, they also check <code>.agents/context/</code> and <code>docs/</code>.</p>
|
||||
<p>The detector's design-system rules use the same root-first behavior for <code>DESIGN.md</code>. For generated design metadata, the primary path is <code>.impeccable/design.json</code>. Legacy <code>DESIGN.json</code> files are still accepted as fallbacks, but new projects should use <code>.impeccable/design.json</code>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-context-details">
|
||||
<summary>What happens when docs and code disagree</summary>
|
||||
<div>
|
||||
<p><code>PRODUCT.md</code> wins on strategy: audience, tone, register, anti-references, and whether a change should preserve or reject the current identity.</p>
|
||||
<p><code>DESIGN.md</code> wins on visual decisions: color, typography, radius, elevation, component behavior, and system-specific do/don't rules.</p>
|
||||
<p>Existing code still matters. Commands read project files before editing and preserve real conventions when they are stronger or newer than the docs. A stale <code>DESIGN.md</code> is a signal to refresh the docs, not permission to ignore the implementation.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-context-details">
|
||||
<summary>Which detector rules unlock when DESIGN.md exists</summary>
|
||||
<div>
|
||||
<p>When <code>DESIGN.md</code> exists, <code>npx impeccable detect</code> and the design hook unlock design-system checks:</p>
|
||||
<ul>
|
||||
<li><code>design-system-font</code> flags primary fonts not declared in <code>DESIGN.md</code> typography.</li>
|
||||
<li><code>design-system-color</code> flags literal colors outside the documented palette or sidecar ramps.</li>
|
||||
<li><code>design-system-radius</code> flags border-radius values outside the documented rounded scale.</li>
|
||||
</ul>
|
||||
<p>These rules do not run when <code>DESIGN.md</code> is absent, when config disables design-system checks, or when you pass <code>--no-design-system</code> to the detector. See <a href="/docs/detector">Detector CLI</a>.</p>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Detector CLI
|
||||
tagline: "Run Impeccable's deterministic design checks without an AI harness."
|
||||
description: "Use npx impeccable detect on files, directories, stdin, and URLs; understand findings, exit codes, ignores, and design-system-aware checks."
|
||||
section: automation
|
||||
order: 1
|
||||
---
|
||||
|
||||
`npx impeccable detect` runs Impeccable's deterministic design checks directly from the terminal. Use it when you want a fast signal without asking an AI command to review the work.
|
||||
|
||||
## Fast path
|
||||
|
||||
Scan the source folder:
|
||||
|
||||
```bash
|
||||
npx impeccable detect src/
|
||||
```
|
||||
|
||||
Scan one file:
|
||||
|
||||
```bash
|
||||
npx impeccable detect src/components/Card.tsx
|
||||
```
|
||||
|
||||
Scan a rendered page:
|
||||
|
||||
```bash
|
||||
npx impeccable detect https://example.com
|
||||
```
|
||||
|
||||
Use JSON when another script or CI job needs to read the result:
|
||||
|
||||
```bash
|
||||
npx impeccable detect --json src/
|
||||
```
|
||||
|
||||
## What it checks
|
||||
|
||||
The detector looks for design and implementation patterns that are usually visible to users: contrast problems, typography drift, layout overflow, generic AI-design tells, brittle motion, and design-system violations when `DESIGN.md` exists.
|
||||
|
||||
Directories are walked for design-relevant files. HTML files include linked local CSS. Framework files such as JSX, TSX, Vue, Svelte, Astro, and CSS modules get source-text checks. URL targets use a browser and inspect the rendered page.
|
||||
|
||||
## How to read results
|
||||
|
||||
Plain output groups findings by file and prints the rule id, snippet, and explanation. Exit codes are:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | No findings. |
|
||||
| `2` | Findings were detected. |
|
||||
| `1` | The command failed. |
|
||||
|
||||
That makes CI usage straightforward: fail the job on `2`, then decide whether to fix the issue or add a narrow ignore.
|
||||
|
||||
## DESIGN.md awareness
|
||||
|
||||
When a local `DESIGN.md` exists, `detect` loads it by default and enables design-system checks for fonts, literal colors, and border radii. The generated `.impeccable/design.json` sidecar gives those checks richer token and ramp data.
|
||||
|
||||
If the design file is stale, refresh it:
|
||||
|
||||
```text
|
||||
/impeccable document
|
||||
```
|
||||
|
||||
If you need one scan without design-system checks:
|
||||
|
||||
```bash
|
||||
npx impeccable detect --no-design-system src/
|
||||
```
|
||||
|
||||
## Managing intentional findings
|
||||
|
||||
Detector ignores are shared with the design hook:
|
||||
|
||||
```bash
|
||||
npx impeccable ignores list
|
||||
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
npx impeccable ignores add-file "src/legacy/**"
|
||||
```
|
||||
|
||||
Use [Config and ignores](/docs/config) for the full ignore workflow.
|
||||
|
||||
## Details when the default path is not enough
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Scan stdin</summary>
|
||||
<div>
|
||||
<p>If you pipe text into the command with no target, it scans stdin:</p>
|
||||
<pre><code>cat component.css | npx impeccable detect</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Project config and raw scans</summary>
|
||||
<div>
|
||||
<p>By default, <code>detect</code> reads <code>.impeccable/config.json</code> and <code>.impeccable/config.local.json</code>.</p>
|
||||
<p>It respects <code>detector.ignoreRules</code>, <code>detector.ignoreFiles</code>, <code>detector.ignoreValues</code>, and <code>detector.designSystem.enabled</code>.</p>
|
||||
<p>It does not respect <code>hook.enabled</code>; manual scans still run when the automatic hook is disabled.</p>
|
||||
<p>Use <code>--no-config</code> only when you want a raw detector run with no project config, no detector ignores, and no <code>DESIGN.md</code> context.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Provider-specific checks</summary>
|
||||
<div>
|
||||
<p>Some rules are provider-specific and opt in:</p>
|
||||
<pre><code>npx impeccable detect --gpt src/
|
||||
npx impeccable detect --gemini src/</code></pre>
|
||||
<p>Leave them off for normal project quality checks. Turn them on when you specifically want to catch model-family fingerprints.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Where the detector fits</summary>
|
||||
<div>
|
||||
<p>The same detector also powers the design hook, <code>/impeccable audit</code>, the public <a href="/slop">slop catalog</a>, the browser extension, and the local detector lab.</p>
|
||||
<p>Use <a href="/docs/hooks">Design hooks</a> when you want findings inside the agent flow. Use <code>detect</code> when you want a direct terminal signal.</p>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Design hooks
|
||||
tagline: "Automatic detector feedback inside Claude Code, Codex, and Cursor."
|
||||
description: "Install, enable, disable, debug, and tune Impeccable's provider-native design hook for automatic detector feedback on UI file edits."
|
||||
section: automation
|
||||
order: 2
|
||||
---
|
||||
|
||||
The design hook runs Impeccable's detector automatically when an AI coding tool edits UI files. It catches regressions while the agent still has the edit in context.
|
||||
|
||||
## Fast path
|
||||
|
||||
Check hook state inside your AI tool:
|
||||
|
||||
```text
|
||||
/impeccable hooks status
|
||||
```
|
||||
|
||||
Turn the hook on or off for this project:
|
||||
|
||||
```text
|
||||
/impeccable hooks on
|
||||
/impeccable hooks off
|
||||
```
|
||||
|
||||
Installer and updater commands can skip hook setup for one run:
|
||||
|
||||
```bash
|
||||
npx impeccable install --no-hooks
|
||||
npx impeccable update --no-hooks
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
The hook scans direct edits to UI code and styles. When it finds a new issue, it sends the agent a short reminder with the finding and a fix direction.
|
||||
|
||||
Claude Code and Codex run after the edit. Cursor checks proposed writes before they land and blocks only when the detector finds an issue in the proposed UI code.
|
||||
|
||||
Plain `.ts` and `.js` files are scanned, but the hook stays quiet unless it finds something design-relevant.
|
||||
|
||||
## Handling intentional findings
|
||||
|
||||
Persist an exception only after you confirm the finding is intentional. Prefer the narrowest exception:
|
||||
|
||||
```text
|
||||
/impeccable hooks ignore-value overused-font Inter --shared --reason "Brand font"
|
||||
/impeccable hooks ignore-file "src/legacy/Card.tsx"
|
||||
/impeccable hooks ignore-rule side-tab
|
||||
```
|
||||
|
||||
For value-specific rules such as `overused-font`, use `ignore-value` for a specific font. Use `ignore-rule overused-font --all-values` only when you want to suppress the entire rule.
|
||||
|
||||
The terminal equivalent is `npx impeccable ignores ...`, which writes the same detector config. See [Config and ignores](/docs/config).
|
||||
|
||||
## Details when the default path is not enough
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Supported harnesses and approval steps</summary>
|
||||
<div>
|
||||
<p><code>npx impeccable install</code> and <code>npx impeccable update</code> install provider-native hook manifests for Claude Code, Codex, and Cursor.</p>
|
||||
<ul>
|
||||
<li>Claude Code: <code>.claude/settings.local.json</code> by default.</li>
|
||||
<li>Codex: <code>.codex/hooks.json</code>.</li>
|
||||
<li>Cursor: <code>.cursor/hooks.json</code>.</li>
|
||||
</ul>
|
||||
<p>Codex requires one extra approval step. After install or update, open <code>/hooks</code> in Codex and approve the project hook. Codex tracks trust by hook definition, so updates can require approval again.</p>
|
||||
<p>Cursor users should also confirm hooks are enabled in Cursor Settings -> Hooks.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Scanned file types</summary>
|
||||
<div>
|
||||
<p>The hook scans common UI and style files:</p>
|
||||
<p><code>.tsx</code>, <code>.jsx</code>, <code>.html</code>, <code>.vue</code>, <code>.svelte</code>, <code>.astro</code>, <code>.css</code>, <code>.scss</code>, <code>.sass</code>, <code>.less</code>, <code>.ts</code>, and <code>.js</code>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Config and environment overrides</summary>
|
||||
<div>
|
||||
<p>Hook lifecycle settings live under <code>hook</code> in <code>.impeccable/config.json</code>:</p>
|
||||
<pre><code>{
|
||||
"hook": {
|
||||
"enabled": true,
|
||||
"quiet": false,
|
||||
"auditLog": ".impeccable/hook.ndjson"
|
||||
}
|
||||
}</code></pre>
|
||||
<p>Per-developer choices, including install consent, live in <code>.impeccable/config.local.json</code>.</p>
|
||||
<p>Detector filters live under <code>detector</code>, not <code>hook</code>, because they are shared by the hook and the CLI detector.</p>
|
||||
<p>Environment variables still override config for one shell: <code>IMPECCABLE_HOOK_DISABLED</code>, <code>IMPECCABLE_HOOK_QUIET</code>, and <code>IMPECCABLE_HOOK_LOG</code>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="docs-prose-details">
|
||||
<summary>Debugging hook behavior</summary>
|
||||
<div>
|
||||
<p>Start with status:</p>
|
||||
<pre><code>/impeccable hooks status</code></pre>
|
||||
<p>It shows the shared and local config paths, current ignores, hook state, and relevant environment overrides.</p>
|
||||
<p>For invocation logs, set <code>hook.auditLog</code> or use <code>IMPECCABLE_HOOK_LOG</code>. The hook writes one NDJSON line per invocation. Leave audit logging off for normal work.</p>
|
||||
<p>If a manifest is malformed, install/update aborts by default. Re-run with <code>--force</code> only when you want Impeccable to back up the malformed file as <code>.bak</code> and replace it.</p>
|
||||
</div>
|
||||
</details>
|
||||
@@ -65,7 +65,7 @@ The skill scans your code across five dimensions:
|
||||
2. **Performance**: layout thrashing, expensive animations, missing lazy loading, bundle weight.
|
||||
3. **Theming**: hard-coded colors, dark mode coverage, token consistency.
|
||||
4. **Responsive**: breakpoint behavior, touch targets, mobile viewport handling.
|
||||
5. **Anti-patterns**: the same deterministic 25 checks the detector runs.
|
||||
5. **Anti-patterns**: the same deterministic checks the [Detector CLI](/docs/detector) runs.
|
||||
|
||||
Each dimension gets a 0 to 4 score. Each finding gets a severity: P0 blocks the release, P1 should fix this sprint, P2 is next cycle, P3 is polish. You get back a single document you can paste into a ticket tracker.
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ tagline: "Generate a spec-compliant DESIGN.md that captures your visual system s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">The six sections are fixed, in a fixed order, with fixed names. Alongside, <code>DESIGN.json</code> ships as a machine-readable sidecar for the Live Mode design panel.</p>
|
||||
<p class="docs-viz-caption">The six sections are fixed, in a fixed order, with fixed names. Alongside, <code>.impeccable/design.json</code> ships as a machine-readable sidecar for tools that need structured design metadata.</p>
|
||||
</div>
|
||||
|
||||
## When to use it
|
||||
@@ -86,9 +86,9 @@ For projects with no code yet (fresh `init` run, nothing built), there is a seed
|
||||
|
||||
The scan pass finds design assets in priority order: CSS custom properties, Tailwind config, CSS-in-JS themes, design token files, component source, the global stylesheet, and finally computed styles from the live rendered output if a browser is available. It auto-extracts everything it can, then asks one grouped question for the parts that need creative input: the **Creative North Star** (a single named metaphor for the whole system, like "The Editorial Sanctuary"), descriptive color names, the elevation philosophy, and the component character.
|
||||
|
||||
Output is a DESIGN.md with exactly six sections: Overview, Colors, Typography, Elevation, Components, Do's and Don'ts. Headers are fixed character-for-character so the file is parseable by other tools. Alongside it, `DESIGN.json` is written as a machine-readable sidecar. That sidecar is what the live-mode design panel uses to render *this project's* actual button, input, nav, and card tiles instead of a generic approximation.
|
||||
Output is a DESIGN.md with exactly six sections: Overview, Colors, Typography, Elevation, Components, Do's and Don'ts. Headers are fixed character-for-character so the file is parseable by other tools. Alongside it, `.impeccable/design.json` is written as a machine-readable sidecar. That sidecar gives Live Mode and design-aware detector rules structured access to this project's actual palette, type, radii, component snippets, and narrative.
|
||||
|
||||
Every other command reads DESIGN.md on invocation. Variants, polishes, audits, and new features inherit the visual system without being told.
|
||||
Every other command reads DESIGN.md on invocation. Variants, polishes, audits, and new features inherit the visual system without being told. See [Design Context](/docs/context) for how DESIGN.md relates to PRODUCT.md and `.impeccable/design.json`.
|
||||
|
||||
## Try it
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Two files at your project root shape everything the skill does:
|
||||
- **`PRODUCT.md`** says what the project is for: audience, product purpose, voice, anti-references, and whether the surface is brand or product.
|
||||
- **`DESIGN.md`** says how the interface should look: colors, typography, components, elevation, and design rules.
|
||||
|
||||
Every command reads both files before generating. The most important setup choice is **brand vs product**: is this a marketing surface where the impression is the product, or an app surface where design helps someone finish a task? The docs call this choice **register**. See [Brand vs product](/tutorials/brand-vs-product) for examples.
|
||||
Every command reads both files before generating. The most important setup choice is **brand vs product**: is this a marketing surface where the impression is the product, or an app surface where design helps someone finish a task? The docs call this choice **register**. See [Design Context](/docs/context) for examples.
|
||||
|
||||
On first use in a project, `/impeccable` may route you into `init`: a short interview that writes `PRODUCT.md` and offers to write `DESIGN.md`. Future commands read those files without asking again.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ The flow scans the codebase first (README, package.json, components, tokens, bra
|
||||
|
||||
The docs call that choice **register**. It shapes typography, motion, color, and density. After that, init asks only what it could not infer: users, personality in three real words, references and anti-references, accessibility requirements.
|
||||
|
||||
PRODUCT.md is strategic only. No colors, no fonts, no pixel values. Those live in DESIGN.md. Keeping the two files separate is deliberate: strategy can stay stable while the visual system evolves.
|
||||
PRODUCT.md is strategic only. No colors, no fonts, no pixel values. Those live in DESIGN.md. Keeping the two files separate is deliberate: strategy can stay stable while the visual system evolves. See [Design Context](/docs/context) for the full load order and how the files interact.
|
||||
|
||||
It closes by pointing you at the best commands to run next, picked from what the scan turned up: `craft` or `shape` for new work, `critique` or `audit` for what is already there, `live` to iterate visually. No guessing where to begin.
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
title: Brand vs product
|
||||
tagline: "The setup choice that keeps Impeccable from applying the wrong design defaults."
|
||||
order: 3
|
||||
description: "Understand the brand/product choice in /impeccable init: marketing surfaces where the impression is the product, and app surfaces where design helps users finish a task."
|
||||
---
|
||||
|
||||
## The short version
|
||||
|
||||
During `/impeccable init`, the first important choice is this:
|
||||
|
||||
- **Brand surface:** marketing site, landing page, campaign, portfolio, editorial page. The visitor's impression is the product.
|
||||
- **Product surface:** app UI, dashboard, admin screen, workflow tool, settings page. The user is trying to finish a task.
|
||||
|
||||
The docs call this choice **register**. You do not need to use that word. You only need to answer which kind of surface you are working on.
|
||||
|
||||
## Why it matters
|
||||
|
||||
The same visual move can be right in one surface and wrong in another.
|
||||
|
||||
A campaign page can afford a huge image, a strange type choice, one dominant idea per screen, and more expressive motion. It needs to create an impression.
|
||||
|
||||
A dashboard usually needs density, predictable components, readable states, stable navigation, and quieter motion. It needs to help someone act quickly.
|
||||
|
||||
When `PRODUCT.md` stores the right choice, every command adjusts. `/impeccable typeset` will not push editorial display type into a dense admin screen. `/impeccable colorize` will not make a campaign page timid because product UIs usually need restraint.
|
||||
|
||||
## How to choose
|
||||
|
||||
Ask what the person came to do.
|
||||
|
||||
If they came to evaluate, trust, remember, compare, or feel a brand: choose **brand**.
|
||||
|
||||
Examples:
|
||||
|
||||
- SaaS landing page
|
||||
- Product launch page
|
||||
- Agency portfolio
|
||||
- Restaurant homepage
|
||||
- Conference site
|
||||
- Case study page
|
||||
|
||||
If they came to configure, monitor, submit, search, compare data, or complete a workflow: choose **product**.
|
||||
|
||||
Examples:
|
||||
|
||||
- Analytics dashboard
|
||||
- Checkout flow
|
||||
- Settings screen
|
||||
- Admin table
|
||||
- Onboarding flow inside an app
|
||||
- Internal operations tool
|
||||
|
||||
## Mixed projects
|
||||
|
||||
Many codebases have both. A SaaS project can have a brand landing page and a product dashboard.
|
||||
|
||||
Set the project default to the surface you work on most, then be specific in the command:
|
||||
|
||||
```
|
||||
/impeccable polish the marketing homepage as a brand surface
|
||||
```
|
||||
|
||||
```
|
||||
/impeccable audit the billing settings as a product surface
|
||||
```
|
||||
|
||||
You can also edit `PRODUCT.md` directly if the default is wrong.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **Calling everything product because it is a software company.** A landing page is still a brand surface.
|
||||
- **Calling everything brand because the visual identity matters.** A dashboard can be on-brand and still be a product surface.
|
||||
- **Skipping the choice.** Impeccable can still work, but it will ask more questions and lean on safer defaults.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Critique with the visual overlay
|
||||
tagline: "Use /impeccable critique plus the browser overlay to review a live page with ground truth."
|
||||
order: 4
|
||||
order: 3
|
||||
description: "Run a full design critique that combines LLM assessment, the automated detector, and a live browser overlay so you can see exactly which elements trigger which anti-patterns on the page you're looking at."
|
||||
---
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
title: Getting started
|
||||
tagline: "From install to your first polish pass in about ten minutes."
|
||||
order: 1
|
||||
description: "Install Impeccable, run /impeccable init once to establish project context, and run /impeccable polish on something that already exists. The fastest path to seeing what Impeccable changes about AI-generated design."
|
||||
description: "Install Impeccable, run /impeccable init once to establish design context, and run /impeccable polish on something that already exists. The fastest path to seeing what Impeccable changes about AI-generated design."
|
||||
---
|
||||
|
||||
## What you'll build
|
||||
|
||||
You will end this tutorial with Impeccable installed, project context saved, and one existing page improved with a polish pass. Total time: about ten minutes.
|
||||
You will end this tutorial with Impeccable installed, design context saved, and one existing page improved with a polish pass. Total time: about ten minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -48,6 +48,8 @@ Prefer a different setup? Claude Code users can install the plugin with `/plugin
|
||||
|
||||
When a new version ships later, run `npx impeccable update` from the same project root. `npx impeccable check` tells you first whether you are behind, and plugin users update from the `/plugin` menu instead.
|
||||
|
||||
On Claude Code, Codex, and Cursor, the installer can also add Impeccable's automatic design hook. See [Design hooks](/docs/hooks) for harness-specific behavior and approval steps.
|
||||
|
||||
## Step 2. Set up Impeccable for your project
|
||||
|
||||
This is the most important step. Design without context produces generic output. The `/impeccable init` command runs a short setup interview and writes `PRODUCT.md` at the root of your project.
|
||||
@@ -63,7 +65,7 @@ The first question is simple: is this a **brand surface** or a **product surface
|
||||
- **Brand surface:** marketing site, landing page, campaign, portfolio. The impression is the product.
|
||||
- **Product surface:** app UI, dashboard, admin, workflow tool. The design helps someone finish a task.
|
||||
|
||||
The docs call this choice **register**. It changes the defaults Impeccable uses for type, color, density, and motion. See [Brand vs product](/tutorials/brand-vs-product) for examples. Init forms a hypothesis from your codebase and asks you to confirm it.
|
||||
The docs call this choice **register**. It changes the defaults Impeccable uses for type, color, density, and motion. See [Design Context](/docs/context) for examples. Init forms a hypothesis from your codebase and asks you to confirm it.
|
||||
|
||||
Then a handful of shorter questions:
|
||||
|
||||
@@ -82,7 +84,7 @@ At the end of `/impeccable init`, the skill offers to run `/impeccable document`
|
||||
|
||||
On a fresh project with no visual system yet, document asks a few setup questions and writes a starting scaffold. Refresh it once there is real code.
|
||||
|
||||
`PRODUCT.md` carries strategy (who, what, why). `DESIGN.md` carries visuals (colors, typography, components). Every command reads both before generating.
|
||||
`PRODUCT.md` carries strategy (who, what, why). `DESIGN.md` carries visuals (colors, typography, components). Every command reads both before generating. See [Design Context](/docs/context) for the full model.
|
||||
|
||||
## Step 3. Polish something
|
||||
|
||||
@@ -112,6 +114,7 @@ Review the diff. If something does not feel right, ask the model to explain the
|
||||
- [Iterate visually with Live Mode](/tutorials/iterate-live) opens a browser picker on your dev server, generates three production-quality variants per element, and writes the accepted one back to source.
|
||||
- `/impeccable critique the landing page` runs a full design review with scoring, persona tests, and automated detection. It is the best way to find what to fix next.
|
||||
- `/impeccable audit the checkout` runs accessibility, performance, theming, responsive, and anti-pattern checks against the implementation. Useful before shipping.
|
||||
- `npx impeccable detect src/` runs the deterministic detector directly from the terminal. See [Detector CLI](/docs/detector).
|
||||
- `/impeccable craft a pricing page for enterprise customers` runs the full shape-then-build flow on a brand new feature.
|
||||
- **Pin your favorites.** If you reach for one command constantly, `/impeccable pin audit` makes `/audit` work as a standalone shortcut without reversing the consolidation.
|
||||
- `/impeccable redo this hero section` works too. Any description after `/impeccable` applies the design principles to the task.
|
||||
|
||||
@@ -120,4 +120,4 @@ The stop also strips the `<script>` tag from your dev entry and stops the helper
|
||||
- **The picker never appears on the page.** Either the helper did not start (look for errors in the terminal) or CSP is blocking the inject. Re-run `/impeccable live` and let it re-check CSP. If you declined the patch on first run, delete the `cspChecked` line in `.impeccable/live/config.json` and re-run.
|
||||
- **"element lives in a generated file"** on Go. Live detected that the picked element is in a compiled output, not a source file. It routes the accept through a fallback path so the variant still lands in true source. Follow the hint; don't force-accept into the generated file.
|
||||
- **Variants don't feel brand-aligned.** Check that `PRODUCT.md` and `DESIGN.md` exist at the project root. Without them, live leans toward generic defaults. Run `/impeccable init` and `/impeccable document` first.
|
||||
- **The helper port is in use.** Another live session left its server running. `npx impeccable live stop` releases the port.
|
||||
- **The helper port is in use.** Another live session left its server running. `/impeccable live stop` releases the port.
|
||||
|
||||
@@ -103,7 +103,7 @@ const resolvedOgDescription = ogDescription || description;
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@300;400;500;600;700&family=Alumni+Sans:wght@400;500;600;700&family=Alumni+Sans+Pinstripe&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&family=Instrument+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@300;400;500;600;700&family=Alumni+Sans:wght@400;500;600;700&family=Alumni+Sans+Pinstripe&display=swap" rel="stylesheet">
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class={bodyClass}>
|
||||
|
||||
@@ -12,17 +12,19 @@ interface Props {
|
||||
tagline: string;
|
||||
slug: string;
|
||||
category: string;
|
||||
pageKind?: 'command' | 'reference';
|
||||
allCommands: { slug: string; category: string }[];
|
||||
}
|
||||
|
||||
const { title, description, tagline, slug } = Astro.props;
|
||||
const { title, description, tagline, slug, pageKind = 'command' } = Astro.props;
|
||||
const isReference = pageKind === 'reference';
|
||||
|
||||
// Decide hero layout at build time: when a command has a registered demo
|
||||
// (in site/scripts/demos/commands/<id>.js), the hero is a two-column grid
|
||||
// with the demo on the left and h1/lede on the right: same editorial
|
||||
// before/after side-by-side that the old site used. When no demo exists,
|
||||
// the hero stays single-column.
|
||||
const hasDemo = !!getCommandDemo(slug);
|
||||
const hasDemo = !isReference && !!getCommandDemo(slug);
|
||||
|
||||
const relationships = COMMAND_RELATIONSHIPS[slug] || {};
|
||||
---
|
||||
@@ -32,10 +34,10 @@ const relationships = COMMAND_RELATIONSHIPS[slug] || {};
|
||||
description={description}
|
||||
activeNav="docs"
|
||||
canonicalPath={`/docs/${slug}`}
|
||||
bodyClass="sub-page skills-layout-page docs-kinpaku"
|
||||
bodyClass="sub-page skills-layout-page docs-kinpaku kinpaku-chrome"
|
||||
>
|
||||
<div class="skills-layout">
|
||||
<DocsSidebar activeCommand={slug} />
|
||||
<DocsSidebar activeCommand={isReference ? undefined : slug} activeReference={isReference ? slug : undefined} />
|
||||
|
||||
<div class="skills-main">
|
||||
<div class="skills-detail">
|
||||
@@ -46,7 +48,6 @@ const relationships = COMMAND_RELATIONSHIPS[slug] || {};
|
||||
source the homepage uses. */
|
||||
<div class="docs-hero docs-hero--with-demo">
|
||||
<header class="sub-page-header">
|
||||
<span class="sub-page-prefix">/impeccable</span>
|
||||
<h1 class="sub-page-title">{slug}</h1>
|
||||
{tagline && <p class="sub-page-lede">{tagline}</p>}
|
||||
</header>
|
||||
@@ -54,8 +55,7 @@ const relationships = COMMAND_RELATIONSHIPS[slug] || {};
|
||||
</div>
|
||||
) : (
|
||||
<header class="sub-page-header">
|
||||
<span class="sub-page-prefix">/impeccable</span>
|
||||
<h1 class="sub-page-title">{slug}</h1>
|
||||
<h1 class="sub-page-title">{title}</h1>
|
||||
{tagline && <p class="sub-page-lede">{tagline}</p>}
|
||||
</header>
|
||||
)}
|
||||
@@ -64,7 +64,7 @@ const relationships = COMMAND_RELATIONSHIPS[slug] || {};
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{(relationships.leadsTo || relationships.pairs || relationships.combinesWith) && (
|
||||
{!isReference && (relationships.leadsTo || relationships.pairs || relationships.combinesWith) && (
|
||||
<footer class="skills-relationships">
|
||||
<h2 class="skills-relationships-title">Related commands</h2>
|
||||
<div class="skills-relationships-list">
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Shiki themes mapped to DESIGN.md / site/styles/kinpaku-tokens.css.
|
||||
// TextMate themes use sRGB hex; each value below is the browser sRGB result
|
||||
// of the corresponding OKLCH token so design-system detection can match it.
|
||||
|
||||
const dark = {
|
||||
bg: '#000000', // lacquer-deep
|
||||
fg: '#D7D7D7', // text
|
||||
strong: '#E1E1E1', // champagne
|
||||
muted: '#A4A4A4', // text-muted
|
||||
faint: '#868686', // text-faint
|
||||
gold: '#DDAB46', // kinpaku-rich
|
||||
goldDeep: '#9F7D45', // kinpaku-deep
|
||||
patina: '#68C3BD', // patina-text
|
||||
patinaPale: '#8ED3CC', // patina-pale
|
||||
};
|
||||
|
||||
const light = {
|
||||
bg: '#FDFCF6', // light-paper-raised
|
||||
fg: '#242218', // light-text
|
||||
strong: '#141207', // light-ink
|
||||
muted: '#58554C', // light-muted
|
||||
faint: '#74726A', // light-faint
|
||||
patina: '#146F69', // patina-deep
|
||||
warning: '#B23B1D', // vermilion-warning-light
|
||||
success: '#1D5522', // success-on-paper
|
||||
neutral: '#3A3A3A', // neutral-35
|
||||
};
|
||||
|
||||
function theme(name, type, colors) {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
colors: {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: [
|
||||
'comment',
|
||||
'punctuation.definition.comment',
|
||||
],
|
||||
settings: {
|
||||
foreground: colors.muted,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'keyword',
|
||||
'storage',
|
||||
'storage.type',
|
||||
'support.type.property-name',
|
||||
],
|
||||
settings: { foreground: colors.patina },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'string',
|
||||
'constant.other.symbol',
|
||||
'markup.inline.raw.string',
|
||||
],
|
||||
settings: { foreground: colors.success || colors.patinaPale },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'constant.numeric',
|
||||
'constant.language',
|
||||
'constant.character',
|
||||
'variable.language',
|
||||
],
|
||||
settings: { foreground: colors.warning || colors.gold },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'variable.function',
|
||||
],
|
||||
settings: { foreground: colors.strong },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
'entity.name.type',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: colors.goldDeep || colors.gold || colors.patina },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable',
|
||||
'meta.object-literal.key',
|
||||
'support.variable',
|
||||
'support.constant',
|
||||
],
|
||||
settings: { foreground: colors.patinaPale || colors.neutral },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'punctuation',
|
||||
'meta.brace',
|
||||
'meta.delimiter',
|
||||
'keyword.operator',
|
||||
],
|
||||
settings: { foreground: colors.faint },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'markup.heading',
|
||||
'markup.bold',
|
||||
'entity.name.section',
|
||||
],
|
||||
settings: {
|
||||
foreground: colors.strong,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'markup.italic',
|
||||
'markup.quote',
|
||||
],
|
||||
settings: {
|
||||
foreground: colors.muted,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const impeccableShikiThemes = {
|
||||
light: theme('impeccable-kinpaku-light', 'light', light),
|
||||
dark: theme('impeccable-kinpaku-dark', 'dark', dark),
|
||||
};
|
||||
@@ -23,8 +23,19 @@ import '../styles/changelog-faq-kinpaku.css';
|
||||
<button type="button" class="cf-filter-btn" data-cf-filter="all" aria-pressed="false">All</button>
|
||||
</div>
|
||||
|
||||
<article id="v3.6.0" class="cf-entry cf-entry--current">
|
||||
<header class="cf-entry-head"><span class="cf-version">v3.6.0</span><span class="cf-date">June 14, 2026</span><span class="cf-current-badge">Current</span></header>
|
||||
<article id="v3.7.0" class="cf-entry cf-entry--current">
|
||||
<header class="cf-entry-head"><span class="cf-version">v3.7.0</span><span class="cf-date">June 16, 2026</span><span class="cf-current-badge">Current</span></header>
|
||||
<p class="cf-entry-lead">Impeccable now carries project design context into hooks, the detector, and docs. The agent can compare edits against your actual typography, color, and radius system instead of only using global anti-pattern rules.</p>
|
||||
<ul class="cf-items">
|
||||
<li><strong>Design-aware hook detections.</strong> When a project has <code>DESIGN.md</code>, hooks and <code>impeccable detect</code> load the local design system and flag font, color, and radius drift against the documented palette, typography stacks, rounded scale, and sidecar tonal ramps.</li>
|
||||
<li><strong>Ignores now work across hooks and the CLI.</strong> Detector exceptions moved into shared config, and <code>impeccable ignores</code> can list, add, and remove rule, file, and value ignores without hand-editing JSON.</li>
|
||||
<li><strong>Docs cover the full project context path.</strong> New reference pages explain hooks, the detector, configuration, and Design Context, including how <code>PRODUCT.md</code>, <code>DESIGN.md</code>, and <code>.impeccable/design.json</code> are loaded and kept current.</li>
|
||||
<li><strong>Docs code blocks now use the Impeccable palette.</strong> Shiki syntax highlighting uses design-system colors in both light and dark mode, with contrast tests so examples stay readable and do not trigger design-system color drift.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article id="v3.6.0" class="cf-entry">
|
||||
<header class="cf-entry-head"><span class="cf-version">v3.6.0</span><span class="cf-date">June 14, 2026</span></header>
|
||||
<p class="cf-entry-lead">Impeccable now helps while the agent is editing, and the core skill is leaner: ablation-tested guidance, project hooks, sharper detector signal, and Live Mode support for Svelte and manual browser edits.</p>
|
||||
<ul class="cf-items">
|
||||
<li><strong>Ablation-tested skill core.</strong> The main skill got leaner after rule-level evals across three providers and four niches. Redundant typography and copy guidance moved out of the always-on prompt, rules with no measurable lift were cut, and examples that primed models to repeat the exact pattern being banned were rewritten. The result is a smaller prompt that spends attention on guidance proven to move design output.</li>
|
||||
|
||||
@@ -239,7 +239,7 @@ const mockupCandidates = listCandidates('mockups');
|
||||
gap: 36px 56px;
|
||||
align-items: start;
|
||||
padding: 40px 0;
|
||||
border-top: 1px solid var(--ks-gold-hairline, oklch(58% 0.065 82 / 0.32));
|
||||
border-top: 1px solid var(--ks-rule);
|
||||
}
|
||||
.logo-dir-row.is-baseline { opacity: 0.72; }
|
||||
|
||||
@@ -258,12 +258,12 @@ const mockupCandidates = listCandidates('mockups');
|
||||
font-family: var(--ks-font-display);
|
||||
font-weight: 600;
|
||||
font-size: 1.4rem;
|
||||
color: var(--ks-champagne, oklch(84% 0.035 82));
|
||||
color: var(--ks-champagne);
|
||||
}
|
||||
.logo-dir-idea {
|
||||
margin: 0;
|
||||
max-width: 42ch;
|
||||
color: var(--ks-text-muted, oklch(63% 0.024 82));
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -278,7 +278,7 @@ const mockupCandidates = listCandidates('mockups');
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ks-text-faint, oklch(52% 0.018 82));
|
||||
color: var(--ks-text-faint);
|
||||
}
|
||||
.logo-dir-lockup { display: inline-flex; align-items: center; gap: 14px; }
|
||||
.logo-dir-word {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SKILL_CATEGORIES } from '../../data/sub-pages-data';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = await getCollection('skills');
|
||||
const referenceEntries = await getCollection('reference');
|
||||
|
||||
const metadataPath = path.join(process.cwd(), 'skill/scripts/command-metadata.json');
|
||||
const commandMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
||||
@@ -16,28 +17,42 @@ export async function getStaticPaths() {
|
||||
category: SKILL_CATEGORIES[e.id] || 'system',
|
||||
}));
|
||||
|
||||
return entries.map(entry => ({
|
||||
params: { slug: entry.id },
|
||||
props: {
|
||||
entry,
|
||||
metadata: commandMetadata[entry.id] || { description: entry.data.tagline, argumentHint: '' },
|
||||
allCommands,
|
||||
},
|
||||
}));
|
||||
return [
|
||||
...entries.map(entry => ({
|
||||
params: { slug: entry.id },
|
||||
props: {
|
||||
entry,
|
||||
kind: 'command',
|
||||
metadata: commandMetadata[entry.id] || { description: entry.data.tagline, argumentHint: '' },
|
||||
allCommands,
|
||||
},
|
||||
})),
|
||||
...referenceEntries.map(entry => ({
|
||||
params: { slug: entry.id },
|
||||
props: {
|
||||
entry,
|
||||
kind: 'reference',
|
||||
metadata: { description: entry.data.description, argumentHint: '' },
|
||||
allCommands,
|
||||
},
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
const { entry, metadata, allCommands } = Astro.props;
|
||||
const { entry, kind, metadata, allCommands } = Astro.props;
|
||||
const { Content } = await render(entry);
|
||||
const slug = entry.id;
|
||||
const category = SKILL_CATEGORIES[slug] || 'system';
|
||||
const isReference = kind === 'reference';
|
||||
---
|
||||
|
||||
<Doc
|
||||
title={slug}
|
||||
title={isReference ? entry.data.title : slug}
|
||||
description={metadata.description}
|
||||
tagline={entry.data.tagline}
|
||||
slug={slug}
|
||||
category={category}
|
||||
pageKind={kind}
|
||||
allCommands={allCommands}
|
||||
>
|
||||
<Content />
|
||||
|
||||
+53
-14
@@ -11,6 +11,8 @@ import {
|
||||
} from '../../data/sub-pages-data';
|
||||
|
||||
const entries = await getCollection('skills');
|
||||
const referenceEntries = (await getCollection('reference'))
|
||||
.sort((a, b) => a.data.order - b.data.order);
|
||||
|
||||
const tutorials = (await getCollection('tutorials'))
|
||||
.sort((a, b) => a.data.order - b.data.order);
|
||||
@@ -90,6 +92,33 @@ const choices = [
|
||||
},
|
||||
];
|
||||
|
||||
const manualTopics = [
|
||||
{
|
||||
topic: 'Design Context',
|
||||
href: '/docs/context',
|
||||
label: 'PRODUCT.md + DESIGN.md',
|
||||
line: 'What gets loaded, when it wins, and how the sidecar fits.',
|
||||
},
|
||||
{
|
||||
topic: 'Detector CLI',
|
||||
href: '/docs/detector',
|
||||
label: 'npx impeccable detect',
|
||||
line: 'Run deterministic checks on files, folders, stdin, and URLs.',
|
||||
},
|
||||
{
|
||||
topic: 'Design hooks',
|
||||
href: '/docs/hooks',
|
||||
label: '/impeccable hooks',
|
||||
line: 'Automatic detector feedback inside Claude Code, Codex, and Cursor.',
|
||||
},
|
||||
{
|
||||
topic: 'Config and ignores',
|
||||
href: '/docs/config',
|
||||
label: '.impeccable/config.json',
|
||||
line: 'Shared exceptions, private overrides, and design-system toggles.',
|
||||
},
|
||||
].filter(topic => referenceEntries.some(entry => `/docs/${entry.id}` === topic.href));
|
||||
|
||||
const chains: { id: string; a: string; b: string; arrow: string; line: string }[] = [
|
||||
{ id: 'bolder-quieter', a: 'bolder', b: 'quieter', arrow: '↔', line: 'Two halves of voice. Loud or restrained, never neutral.' },
|
||||
{ id: 'audit-harden', a: 'audit', b: 'harden', arrow: '→', line: 'Find the technical issues. Then fix them.' },
|
||||
@@ -100,7 +129,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
|
||||
|
||||
<Base
|
||||
title="Docs | Impeccable"
|
||||
description="Start using /impeccable: install, run init, create project context, then choose the right command for the job."
|
||||
description="Start using /impeccable: install, run init, create design context, then choose the right command for the job."
|
||||
activeNav="docs"
|
||||
canonicalPath="/docs"
|
||||
bodyClass="sub-page skills-layout-page docs-kinpaku kinpaku-chrome"
|
||||
@@ -113,15 +142,11 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
|
||||
|
||||
<section class="docs-start-rail" aria-labelledby="docs-start-h">
|
||||
<header class="docs-start-rail-head">
|
||||
<h1 class="docs-start-rail-title" id="docs-start-h">Start with <span>/impeccable</span>.</h1>
|
||||
<h1 class="docs-start-rail-title" id="docs-start-h">Start with <span>/impeccable</span></h1>
|
||||
<p class="docs-start-rail-lede">Install, run setup, then try one command on an existing page.</p>
|
||||
</header>
|
||||
|
||||
<div class="docs-start-rail-sequence">
|
||||
<div class="docs-start-rail-divider docs-start-rail-divider--top" aria-hidden="true">
|
||||
<div class="ksd-divider ksd-divider--double-notch"></div>
|
||||
</div>
|
||||
|
||||
<ol class="docs-start-rail-steps docs-start-rail-steps--number-gutter docs-start-rail-steps--inner-inset" aria-label="Quick start steps">
|
||||
{startSteps.map((step, i) => (
|
||||
<li class="docs-start-rail-step">
|
||||
@@ -133,9 +158,6 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div class="docs-start-rail-divider docs-start-rail-divider--bottom" aria-hidden="true">
|
||||
<div class="ksd-divider ksd-divider--double-notch"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="docs-start-rail-note">Full walkthrough: <a href="/tutorials/getting-started">Getting started</a>.</p>
|
||||
@@ -143,7 +165,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
|
||||
|
||||
<section class="docs-chooser-section" aria-labelledby="docs-chooser-h">
|
||||
<header class="docs-chooser-head">
|
||||
<h2 class="docs-chooser-title" id="docs-chooser-h">Choose a command.</h2>
|
||||
<h2 class="docs-chooser-title" id="docs-chooser-h">Choose a command</h2>
|
||||
</header>
|
||||
|
||||
<div class="docs-chooser-list">
|
||||
@@ -157,10 +179,27 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="docs-manual-section" aria-labelledby="docs-manual-h">
|
||||
<header class="docs-manual-head">
|
||||
<h2 class="docs-manual-title" id="docs-manual-h">Understand the system</h2>
|
||||
<p class="docs-manual-sub">Commands are only one layer. These docs explain the files, checks, hooks, and config that make them behave consistently.</p>
|
||||
</header>
|
||||
|
||||
<div class="docs-chooser-list docs-manual-list">
|
||||
{manualTopics.map(topic => (
|
||||
<a class="docs-chooser-list-row" href={topic.href}>
|
||||
<span class="docs-chooser-list-need">{topic.topic}</span>
|
||||
<span class="docs-chooser-list-command">{topic.label}</span>
|
||||
<span class="docs-chooser-list-line">{topic.line}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live mode: visual iteration -->
|
||||
<section class="docs-live" aria-labelledby="docs-live-h">
|
||||
<div class="docs-live-copy">
|
||||
<h2 class="docs-live-title" id="docs-live-h">Iterate where you can see it.</h2>
|
||||
<h2 class="docs-live-title" id="docs-live-h">Iterate where you can see it</h2>
|
||||
<p class="docs-live-lede">Use Live Mode when an issue is easier to point at than describe. Pick an element, generate variants, accept one into source.</p>
|
||||
<p class="docs-live-cta">
|
||||
<a class="ks-button ks-button-primary" href="/docs/live">
|
||||
@@ -200,7 +239,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
|
||||
<!-- A typical session: linear workflow -->
|
||||
<section class="docs-flow-section">
|
||||
<header class="docs-flow-head">
|
||||
<h2 class="docs-flow-title">Full session path.</h2>
|
||||
<h2 class="docs-flow-title">Full session path</h2>
|
||||
<p class="docs-flow-sub">Plan, build, review, refine.</p>
|
||||
</header>
|
||||
|
||||
@@ -229,7 +268,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
|
||||
<!-- Pairs and chains: the schema -->
|
||||
<section class="docs-chains-section">
|
||||
<header class="docs-chains-head">
|
||||
<h2 class="docs-chains-title">Useful command pairs.</h2>
|
||||
<h2 class="docs-chains-title">Useful command pairs</h2>
|
||||
<p class="docs-chains-sub">Use these when one pass is not enough.</p>
|
||||
</header>
|
||||
|
||||
@@ -250,7 +289,7 @@ const chains: { id: string; a: string; b: string; arrow: string; line: string }[
|
||||
<!-- Full reference: the cheatsheet -->
|
||||
<section class="docs-reference-section" aria-labelledby="docs-reference-h">
|
||||
<header class="docs-reference-head">
|
||||
<h2 class="docs-reference-title" id="docs-reference-h">Full command reference.</h2>
|
||||
<h2 class="docs-reference-title" id="docs-reference-h">Full command reference</h2>
|
||||
</header>
|
||||
|
||||
<div class="docs-reference">
|
||||
|
||||
@@ -521,7 +521,7 @@ import '../styles/testimonials.css';
|
||||
<article class="ks-bento-tile ks-bento-tile--span-6" id="why-ci">
|
||||
<span class="ks-bento-num" data-color="patina">06</span>
|
||||
<h3 class="why-panel-title">Block slop before it ships.</h3>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 41 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 44 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<div class="why-visual why-visual--ci">
|
||||
<div class="why-ci-window">
|
||||
<div class="why-ci-header">
|
||||
@@ -672,7 +672,7 @@ import '../styles/testimonials.css';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="downloads-rebuild-note">Works with Cursor, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, and every other major AI coding harness. <code>install</code> sets up the build for your harness. Then run <code>/impeccable init</code> inside your AI tool so the skill can save your project context. <code>update</code> pulls the latest version. Run <code>npx impeccable check</code> to see if you're behind. Installed the Claude Code plugin? Update it from the <code>/plugin</code> menu instead.</p>
|
||||
<p class="downloads-rebuild-note">Works with Cursor, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, and every other major AI coding harness. <code>install</code> sets up the build for your harness. Then run <code>/impeccable init</code> inside your AI tool so the skill can save your design context. <code>update</code> pulls the latest version. Run <code>npx impeccable check</code> to see if you're behind. Installed the Claude Code plugin? Update it from the <code>/plugin</code> menu instead.</p>
|
||||
|
||||
<details class="downloads-rebuild-alts">
|
||||
<summary class="downloads-rebuild-alts-summary">
|
||||
@@ -729,7 +729,7 @@ import '../styles/testimonials.css';
|
||||
</li>
|
||||
<li>
|
||||
<strong>CLI for CI</strong>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 41 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 44 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<a href="https://www.npmjs.com/package/impeccable" target="_blank" rel="noopener">View on npm →</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -897,8 +897,8 @@ import '../../styles/slop-kinpaku.css';
|
||||
</article>
|
||||
<article class="visual-mode-method">
|
||||
<p class="visual-mode-method-label">Standalone CLI</p>
|
||||
<h3 class="visual-mode-method-name"><code>npx impeccable live</code></h3>
|
||||
<p class="visual-mode-method-desc">Starts a local server that serves the detector script. Inject it into any page via a <code><script></code> tag to see the overlay. Works on your own dev server, a staging URL, or anyone's live page.</p>
|
||||
<h3 class="visual-mode-method-name"><a href="/docs/detector"><code>npx impeccable detect</code></a></h3>
|
||||
<p class="visual-mode-method-desc">Runs the deterministic detector from the terminal on files, directories, stdin, or rendered URLs. Use it for CI and local checks without opening an AI harness.</p>
|
||||
</article>
|
||||
<article class="visual-mode-method">
|
||||
<p class="visual-mode-method-label">Easiest</p>
|
||||
|
||||
@@ -30,7 +30,6 @@ const { Content } = await render(entry);
|
||||
<div class="skills-main">
|
||||
<div class="skills-detail">
|
||||
<header class="sub-page-header">
|
||||
<span class="sub-page-prefix">Tutorial</span>
|
||||
<h1 class="sub-page-title">{entry.data.title}</h1>
|
||||
{entry.data.tagline && <p class="sub-page-lede">{entry.data.tagline}</p>}
|
||||
</header>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+211
-58
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+15
-19
@@ -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,
|
||||
|
||||
+11
-11
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+17
-17
@@ -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 */
|
||||
|
||||
@@ -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 <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
|
||||
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
|
||||
| `ignore-rule <id>` | Append `<id>` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. |
|
||||
| `ignore-file <glob>` | Append `<glob>` to `detector.ignoreFiles`. |
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <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. |
|
||||
|
||||
+111
-49
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
|
||||
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
|
||||
* in the current project.
|
||||
* `/impeccable hooks <on|off|status|reset>` — 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 <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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+265
-25
@@ -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),
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(92% 0 0 / 0.13);flex-shrink:0">
|
||||
<span style="background:oklch(84% 0.19 80.46)"></span>
|
||||
<span style="background:oklch(70% 0.12 188)"></span>
|
||||
<span style="background:oklch(84% 0.035 82)"></span>
|
||||
<span style="background:oklch(34% 0.014 82)"></span>
|
||||
<span style="background:oklch(91% 0 0)"></span>
|
||||
<span style="background:oklch(34% 0 0)"></span>
|
||||
</span>`,
|
||||
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};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(`
|
||||
<a href="https://github.com/example/repo/issues/155">#155</a>
|
||||
<span class="spread-flow-icon">↔</span>
|
||||
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 = \`<span style="font-family:\${labelFont || FONT};">Pick</span>\`;
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 <style> content');
|
||||
assert.equal(
|
||||
designFindings.find((r) => /Flag Color Hot Pink/.test(r.snippet || ''))?.line,
|
||||
37,
|
||||
'deduped HTML design findings should keep the source line when available',
|
||||
);
|
||||
assert.equal(
|
||||
designFindings.find((r) => /Flag Radius Eighteen/.test(r.snippet || ''))?.line,
|
||||
40,
|
||||
'deduped radius findings should keep the source line when available',
|
||||
);
|
||||
|
||||
for (const label of [
|
||||
'Flag Font Unsupported',
|
||||
'Flag Color Hot Pink',
|
||||
'Flag Background Cyan',
|
||||
'Flag Border Teal',
|
||||
'Flag Radius Eighteen',
|
||||
]) {
|
||||
assert.match(snippets, new RegExp(label), `expected ${label} to be flagged`);
|
||||
}
|
||||
for (const label of [
|
||||
'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 Pill Radius',
|
||||
]) {
|
||||
assert.doesNotMatch(snippets, new RegExp(label), `${label} should pass`);
|
||||
}
|
||||
});
|
||||
|
||||
it('numbered-section-markers: visible sequence flags while script/style/svg internals pass', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'numbered-section-markers.html'));
|
||||
const numbered = f.filter(r => r.antipattern === 'numbered-section-markers');
|
||||
|
||||
@@ -19,6 +19,12 @@ const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
||||
const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
|
||||
const BENCH_SCRIPT = path.join(import.meta.dir, '..', 'scripts', 'benchmark-detector.mjs');
|
||||
|
||||
function withoutDesignSystemArgs(args) {
|
||||
return args[0] === 'detect'
|
||||
? ['detect', '--no-design-system', '--no-config', ...args.slice(1)]
|
||||
: ['--no-design-system', '--no-config', ...args];
|
||||
}
|
||||
|
||||
function writeStaticFixture(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-static-'));
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
@@ -856,7 +862,11 @@ describe('walkDir', () => {
|
||||
|
||||
describe('CLI', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
const result = spawnSync('node', [SCRIPT, ...withoutDesignSystemArgs(args)], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
function runIn(cwd, ...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
@@ -918,6 +928,149 @@ describe('CLI', () => {
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('local DESIGN.md enables design-system rules by default and --no-design-system disables them', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cli-design-system-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), `---
|
||||
typography:
|
||||
body:
|
||||
fontFamily: "IBM Plex Sans, Arial, sans-serif"
|
||||
colors:
|
||||
ink: "#241f1a"
|
||||
paper: "#f7f4ee"
|
||||
rounded:
|
||||
md: "8px"
|
||||
---
|
||||
|
||||
# Design System
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'index.html'), `
|
||||
<section style="font-family: 'Poppins', sans-serif; color: #ff00aa; background: #f7f4ee; border-radius: 18px;">
|
||||
Design drift
|
||||
</section>
|
||||
`);
|
||||
|
||||
const active = runIn(dir, '--json', 'index.html');
|
||||
expect(active.code).toBe(2);
|
||||
const activeIds = JSON.parse(active.stdout).map((finding) => finding.antipattern);
|
||||
expect(activeIds).toContain('design-system-font');
|
||||
expect(activeIds).toContain('design-system-color');
|
||||
expect(activeIds).toContain('design-system-radius');
|
||||
|
||||
const disabled = runIn(dir, '--json', '--no-design-system', 'index.html');
|
||||
const disabledIds = JSON.parse(disabled.stdout).map((finding) => finding.antipattern);
|
||||
expect(disabledIds.some((id) => id.startsWith('design-system-'))).toBe(false);
|
||||
|
||||
const raw = runIn(dir, '--json', '--no-config', 'index.html');
|
||||
const rawIds = JSON.parse(raw.stdout).map((finding) => finding.antipattern);
|
||||
expect(rawIds.some((id) => id.startsWith('design-system-'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detector designSystem.enabled=false disables CLI design-system rules', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cli-design-disabled-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.impeccable', 'config.json'), JSON.stringify({
|
||||
detector: { designSystem: { enabled: false } },
|
||||
}));
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), `---
|
||||
typography:
|
||||
body:
|
||||
fontFamily: "IBM Plex Sans, Arial, sans-serif"
|
||||
colors:
|
||||
ink: "#241f1a"
|
||||
paper: "#f7f4ee"
|
||||
rounded:
|
||||
md: "8px"
|
||||
---
|
||||
|
||||
# Design System
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'index.html'), `
|
||||
<section style="font-family: 'Poppins', sans-serif; color: #ff00aa; background: #f7f4ee; border-radius: 18px;">
|
||||
Design drift
|
||||
</section>
|
||||
`);
|
||||
|
||||
const result = runIn(dir, '--json', 'index.html');
|
||||
const ids = JSON.parse(result.stdout).map((finding) => finding.antipattern);
|
||||
expect(ids.some((id) => id.startsWith('design-system-'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('respects .impeccable config ignoreFiles like the hook', async () => {
|
||||
await withStaticFixture({
|
||||
'.impeccable/config.json': JSON.stringify({
|
||||
detector: { ignoreFiles: ['src/noisy.css'] },
|
||||
}),
|
||||
'src/noisy.css': "body { font-family: 'Inter', sans-serif; }",
|
||||
}, ({ dir }) => {
|
||||
const { stdout, code } = runIn(dir, '--json', 'src');
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(stdout.trim())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('respects .impeccable config ignoreRules like the hook', async () => {
|
||||
await withStaticFixture({
|
||||
'.impeccable/config.json': JSON.stringify({
|
||||
detector: { ignoreRules: ['side-tab'] },
|
||||
}),
|
||||
'src/card.css': '.card { border-left: 4px solid #3b82f6; border-radius: 12px; }',
|
||||
}, ({ dir }) => {
|
||||
const { stdout, code } = runIn(dir, '--json', 'src/card.css');
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(stdout.trim())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('respects .impeccable config ignoreValues like the hook', async () => {
|
||||
await withStaticFixture({
|
||||
'.impeccable/config.json': JSON.stringify({
|
||||
detector: {
|
||||
ignoreValues: [
|
||||
{ rule: 'overused-font', value: 'Inter' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
'src/fonts.css': [
|
||||
"body { font-family: 'Inter', sans-serif; }",
|
||||
"h1 { font-family: 'Roboto', sans-serif; }",
|
||||
].join('\n'),
|
||||
}, ({ dir }) => {
|
||||
const { stdout, code } = runIn(dir, '--json', 'src/fonts.css');
|
||||
expect(code).toBe(2);
|
||||
const snippets = JSON.parse(stdout.trim()).map(f => f.snippet).join('\n');
|
||||
expect(snippets).not.toContain('Inter');
|
||||
expect(snippets).toContain('Roboto');
|
||||
});
|
||||
});
|
||||
|
||||
test('respects scoped wildcard ignoreValues like the hook', async () => {
|
||||
await withStaticFixture({
|
||||
'.impeccable/config.json': JSON.stringify({
|
||||
detector: {
|
||||
ignoreValues: [
|
||||
{ rule: 'overused-font', value: '*', files: ['src/main.css'] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
'src/main.css': "body { font-family: 'Inter', sans-serif; }",
|
||||
'src/other.css': "body { font-family: 'Inter', sans-serif; }",
|
||||
}, ({ dir }) => {
|
||||
const { stdout, code } = runIn(dir, '--json', 'src');
|
||||
expect(code).toBe(2);
|
||||
const findings = JSON.parse(stdout.trim());
|
||||
expect(findings.some(f => f.file.endsWith('src/main.css'))).toBe(false);
|
||||
expect(findings.some(f => f.file.endsWith('src/other.css'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('warns on nonexistent path', () => {
|
||||
const { stderr } = run('/nonexistent/file/xyz.html');
|
||||
expect(stderr).toContain('Warning');
|
||||
@@ -1165,7 +1318,7 @@ describe('detectText -- CSS-in-JS', () => {
|
||||
|
||||
describe('CLI -- framework fixtures', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
const result = spawnSync('node', [SCRIPT, ...withoutDesignSystemArgs(args)], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
@@ -1223,7 +1376,7 @@ describe('CLI -- Next.js + Tailwind project', () => {
|
||||
let stderr;
|
||||
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
const result = spawnSync('node', [SCRIPT, ...withoutDesignSystemArgs(args)], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
@@ -1281,7 +1434,7 @@ describe('CLI -- Next.js + Tailwind project', () => {
|
||||
|
||||
describe('CLI -- Next.js + CSS Modules project', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
const result = spawnSync('node', [SCRIPT, ...withoutDesignSystemArgs(args)], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
@@ -1333,7 +1486,7 @@ describe('CLI -- Next.js + CSS Modules project', () => {
|
||||
|
||||
describe('CLI -- Next.js + CSS-in-JS (styled-components) project', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
const result = spawnSync('node', [SCRIPT, ...withoutDesignSystemArgs(args)], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
@@ -1490,7 +1643,7 @@ describe('resolveImport', () => {
|
||||
|
||||
describe('CLI -- multi-file scan', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
const result = spawnSync('node', [SCRIPT, ...withoutDesignSystemArgs(args)], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
@@ -1577,7 +1730,7 @@ describe('FRAMEWORK_CONFIGS', () => {
|
||||
|
||||
describe('CLI -- dev server suggestion', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
const result = spawnSync('node', [SCRIPT, ...withoutDesignSystemArgs(args)], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ function knownRoutes() {
|
||||
}
|
||||
routes.add('/tutorials');
|
||||
|
||||
const referenceDir = path.join(ROOT, 'site/content/reference');
|
||||
if (fs.existsSync(referenceDir)) {
|
||||
for (const file of fs.readdirSync(referenceDir).filter(file => file.endsWith('.md'))) {
|
||||
routes.add(`/docs/${file.replace(/\.md$/, '')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(ROOT, 'site/public/neo-mirai/index.html'))) {
|
||||
routes.add('/neo-mirai');
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<section class="design-system-fixture">
|
||||
<style>
|
||||
.design-system-fixture {
|
||||
--brand-accent: #b8422e;
|
||||
background: #f7f4ee;
|
||||
color: #241f1a;
|
||||
font-family: "IBM Plex Sans", Arial, sans-serif;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.case {
|
||||
margin: 0 0 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d4c7b9;
|
||||
background: #ffffff;
|
||||
color: #241f1a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.fixture-note {
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d4c7b9;
|
||||
background: #f7f4ee;
|
||||
color: #241f1a;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.fixture-note strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.flag-font { font-family: "Poppins", sans-serif; }
|
||||
.flag-color { color: #ff00aa; }
|
||||
.flag-background { background-color: rgb(20, 180, 220); }
|
||||
.flag-border { border-color: #00a982; }
|
||||
.flag-radius { border-radius: 18px; }
|
||||
|
||||
.pass-display-font { font-family: "Avenir Next", Georgia, serif; }
|
||||
.pass-generic-font { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
.pass-token-color { color: var(--brand-accent); }
|
||||
.pass-alpha-color { color: rgba(184, 66, 46, 0.45); }
|
||||
.pass-close-color { color: #bb442f; }
|
||||
.pass-ramp-color { color: #d55a42; }
|
||||
.pass-zero-radius { border-radius: 0; }
|
||||
.pass-percent-radius { border-radius: 50%; }
|
||||
.pass-scale-radius { border-radius: 32px; }
|
||||
.pass-mid-pill-radius { border-radius: 100px; }
|
||||
.pass-pill-radius { border-radius: 999px; }
|
||||
</style>
|
||||
|
||||
<aside class="fixture-note">
|
||||
<strong>Browser note:</strong> design-system detections need injected DESIGN.md context, so this page only shows global/browser findings. Use the CLI or hook tests to exercise the design-system rules.
|
||||
</aside>
|
||||
|
||||
<div class="case flag-font">Flag Font Unsupported</div>
|
||||
<div class="case flag-color">Flag Color Hot Pink</div>
|
||||
<div class="case flag-background">Flag Background Cyan</div>
|
||||
<div class="case flag-border">Flag Border Teal</div>
|
||||
<div class="case flag-radius">Flag Radius Eighteen</div>
|
||||
<div class="case" data-font-source="https://fonts.googleapis.com/css2?family=Poppins:wght@400&display=swap">Flag Google Font Source</div>
|
||||
|
||||
<div class="case pass-display-font">Pass Display Font</div>
|
||||
<div class="case pass-generic-font">Pass Generic Font</div>
|
||||
<div class="case pass-token-color">Pass Token Color</div>
|
||||
<div class="case pass-alpha-color">Pass Alpha Color</div>
|
||||
<div class="case pass-close-color">Pass Close Color</div>
|
||||
<div class="case pass-ramp-color">Pass Ramp Color</div>
|
||||
<div class="case pass-zero-radius">Pass Zero Radius</div>
|
||||
<div class="case pass-percent-radius">Pass Percent Radius</div>
|
||||
<div class="case pass-scale-radius">Pass Scale Radius</div>
|
||||
<div class="case pass-mid-pill-radius">Pass Mid Pill Radius</div>
|
||||
<div class="case pass-pill-radius">Pass Pill Radius</div>
|
||||
</section>
|
||||
+230
-18
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user