@etus/seven-skill 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/seven/SKILL.md +162 -0
- package/.claude/skills/seven/reference/audit.md +120 -0
- package/.claude/skills/seven/reference/brand.md +24 -0
- package/.claude/skills/seven/reference/clarify.md +76 -0
- package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
- package/.claude/skills/seven/reference/motion-design.md +71 -0
- package/.claude/skills/seven/reference/polish.md +55 -0
- package/.claude/skills/seven/reference/product.md +45 -0
- package/.claude/skills/seven/reference/shape.md +85 -0
- package/.claude/skills/seven/reference/spatial-design.md +60 -0
- package/.claude/skills/seven/reference/typography.md +43 -0
- package/.claude/skills/seven/reference/ux-writing.md +47 -0
- package/.claude/skills/seven/scripts/load-context.mjs +84 -0
- package/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +190 -0
- package/NOTICE.md +26 -0
- package/README.md +118 -0
- package/cli/bin/commands/skills.mjs +664 -0
- package/cli/bin/seven.mjs +68 -0
- package/cli/engine/browser/injected/index.mjs +84 -0
- package/cli/engine/cli/main.mjs +215 -0
- package/cli/engine/detect-antipatterns-browser.js +3014 -0
- package/cli/engine/detect-antipatterns.mjs +44 -0
- package/cli/engine/engines/browser/detect-url.mjs +108 -0
- package/cli/engine/engines/regex/detect-text.mjs +508 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
- package/cli/engine/engines/static-html/detect-html.mjs +211 -0
- package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
- package/cli/engine/findings.mjs +28 -0
- package/cli/engine/node/file-system.mjs +212 -0
- package/cli/engine/profile/profiler.mjs +169 -0
- package/cli/engine/registry/seven-antipatterns.mjs +494 -0
- package/cli/engine/rules/checks.mjs +1518 -0
- package/cli/engine/shared/color.mjs +204 -0
- package/cli/engine/shared/constants.mjs +91 -0
- package/cli/engine/shared/page.mjs +9 -0
- package/cli/engine/shared/tokens.mjs +153 -0
- package/cli/engine/token-data.generated.mjs +691 -0
- package/docs/detector-rules.md +605 -0
- package/docs/figma-token-rule-exploration.md +185 -0
- package/package.json +76 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// ETUS Digital — Seven Design System. Detector architecture adapted from
|
|
3
|
+
// impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { isFullPage } from '../../shared/page.mjs'
|
|
9
|
+
import { finding } from '../../findings.mjs'
|
|
10
|
+
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'
|
|
11
|
+
import {
|
|
12
|
+
checkElementBorders,
|
|
13
|
+
checkElementColors,
|
|
14
|
+
checkElementGlow,
|
|
15
|
+
checkElementLucideIcon,
|
|
16
|
+
checkElementMotion,
|
|
17
|
+
checkElementPillOnButton,
|
|
18
|
+
checkElementQuality,
|
|
19
|
+
checkElementTactilePress,
|
|
20
|
+
checkHtmlPatterns,
|
|
21
|
+
checkPageLayout,
|
|
22
|
+
checkPageQualityFromDoc,
|
|
23
|
+
checkPageTypography,
|
|
24
|
+
resolveBackground,
|
|
25
|
+
} from '../../rules/checks.mjs'
|
|
26
|
+
import { detectText } from '../regex/detect-text.mjs'
|
|
27
|
+
import {
|
|
28
|
+
StaticDocument,
|
|
29
|
+
buildStaticStyleMap,
|
|
30
|
+
buildStaticWindow,
|
|
31
|
+
collectStaticCssText,
|
|
32
|
+
} from './css-cascade.mjs'
|
|
33
|
+
|
|
34
|
+
// ─── Static-HTML engine ─────────────────────────────────────────────────────
|
|
35
|
+
// HTML-file detector. Two layers:
|
|
36
|
+
// 1. The Seven text checks run over the raw markup (inline <style>, style=,
|
|
37
|
+
// class= attributes are all part of the file text).
|
|
38
|
+
// 2. The document is parsed with htmlparser2, its CSS cascade is resolved by
|
|
39
|
+
// css-cascade.mjs into a StaticDocument, and per-element + page-level
|
|
40
|
+
// checks run against the computed styles. This surfaces drift that lives
|
|
41
|
+
// only in the resolved cascade — a side-stripe border declared in a
|
|
42
|
+
// stylesheet rule, a low-contrast token pair, mixed radius scales.
|
|
43
|
+
// When the static parser is unavailable the text scan still runs as a fallback.
|
|
44
|
+
|
|
45
|
+
// Per-element rules: each names a selector and a check. `customPropMap` is
|
|
46
|
+
// reserved for var() resolution the cascade already performs upstream.
|
|
47
|
+
const STATIC_ELEMENT_RULES = [
|
|
48
|
+
{
|
|
49
|
+
id: 'side-stripe-border',
|
|
50
|
+
selector: '*',
|
|
51
|
+
run: (el, tag, style) => checkElementBorders(tag, style, null),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'color-rules',
|
|
55
|
+
selector: '*',
|
|
56
|
+
run: (el, tag, style, window) => checkElementColors(el, style, tag, window),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'shadows-not-borders',
|
|
60
|
+
selector: '*',
|
|
61
|
+
run: (el, tag, style, window) =>
|
|
62
|
+
checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window)),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'motion-rules',
|
|
66
|
+
selector: '*',
|
|
67
|
+
run: (el, tag, style) => checkElementMotion(tag, style),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'missing-tactile-press',
|
|
71
|
+
selector: 'button,a,[role]',
|
|
72
|
+
run: (el, tag, style) => checkElementTactilePress(el, style, tag),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'pill-on-button',
|
|
76
|
+
selector: 'button,[role="button"]',
|
|
77
|
+
run: (el, tag, style) => checkElementPillOnButton(el, style, tag),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'non-lucide-icon',
|
|
81
|
+
selector: 'svg',
|
|
82
|
+
run: (el, tag) => checkElementLucideIcon(el, tag),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'quality-rules',
|
|
86
|
+
selector: '*',
|
|
87
|
+
run: (el, tag, style, window) => checkElementQuality(el, style, tag, window),
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} filePath path to an HTML file
|
|
93
|
+
* @param {{ ruleIds?: string[], profile?: object }} [options]
|
|
94
|
+
* @returns {Promise<Array>} normalized findings
|
|
95
|
+
*/
|
|
96
|
+
async function detectHtml(filePath, options = {}) {
|
|
97
|
+
const profile = options?.profile
|
|
98
|
+
const ruleIds = options?.ruleIds || null
|
|
99
|
+
|
|
100
|
+
const html = profileStep(
|
|
101
|
+
profile,
|
|
102
|
+
{ engine: 'static-html', phase: 'setup', ruleId: 'read-html', target: filePath },
|
|
103
|
+
() => fs.readFileSync(filePath, 'utf-8')
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Layer 1: the Seven text checks over the raw markup. Always runs — even on
|
|
107
|
+
// the static-parser success path — so inline style=/class= drift surfaces.
|
|
108
|
+
const findings = detectText(html, filePath, options)
|
|
109
|
+
|
|
110
|
+
let modules
|
|
111
|
+
try {
|
|
112
|
+
modules = await profileStepAsync(
|
|
113
|
+
profile,
|
|
114
|
+
{ engine: 'static-html', phase: 'setup', ruleId: 'import-static-parser', target: filePath },
|
|
115
|
+
async () => {
|
|
116
|
+
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
|
|
117
|
+
import('htmlparser2'),
|
|
118
|
+
import('css-select'),
|
|
119
|
+
import('css-tree'),
|
|
120
|
+
import('domutils'),
|
|
121
|
+
])
|
|
122
|
+
return {
|
|
123
|
+
parseDocument: htmlparser2.parseDocument,
|
|
124
|
+
selectAll: cssSelect.selectAll,
|
|
125
|
+
selectOne: cssSelect.selectOne,
|
|
126
|
+
is: cssSelect.is,
|
|
127
|
+
csstree,
|
|
128
|
+
domutils,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
} catch {
|
|
133
|
+
// Static parser unavailable — the text scan above is the result.
|
|
134
|
+
return findings
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const resolvedPath = path.resolve(filePath)
|
|
138
|
+
const fileDir = path.dirname(resolvedPath)
|
|
139
|
+
let root
|
|
140
|
+
try {
|
|
141
|
+
root = profileStep(
|
|
142
|
+
profile,
|
|
143
|
+
{ engine: 'static-html', phase: 'parse-html', ruleId: 'parse-document', target: filePath },
|
|
144
|
+
() => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true })
|
|
145
|
+
)
|
|
146
|
+
} catch {
|
|
147
|
+
return findings
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules)
|
|
151
|
+
const document = new StaticDocument(root, modules)
|
|
152
|
+
buildStaticStyleMap(root, document, cssText, modules, profile, filePath)
|
|
153
|
+
const window = buildStaticWindow(document)
|
|
154
|
+
|
|
155
|
+
// Layer 2a: per-element checks over the resolved cascade.
|
|
156
|
+
const runElementCheck = (ruleId, callback) =>
|
|
157
|
+
profile
|
|
158
|
+
? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
|
|
159
|
+
: callback()
|
|
160
|
+
|
|
161
|
+
for (const rule of STATIC_ELEMENT_RULES) {
|
|
162
|
+
const elements = document.querySelectorAll(rule.selector)
|
|
163
|
+
for (const el of elements) {
|
|
164
|
+
const tag = el.tagName.toLowerCase()
|
|
165
|
+
const style = window.getComputedStyle(el)
|
|
166
|
+
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window))) {
|
|
167
|
+
if (ruleIds && !ruleIds.includes(f.id)) continue
|
|
168
|
+
findings.push(finding(f.id, filePath, f.snippet))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Layer 2b: page-level checks — only on full pages.
|
|
174
|
+
if (isFullPage(html)) {
|
|
175
|
+
const runPageCheck = (ruleId, callback) =>
|
|
176
|
+
profile
|
|
177
|
+
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
|
|
178
|
+
: callback()
|
|
179
|
+
const pushPage = (raw) => {
|
|
180
|
+
for (const f of raw) {
|
|
181
|
+
if (ruleIds && !ruleIds.includes(f.id)) continue
|
|
182
|
+
findings.push(finding(f.id, filePath, f.snippet))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
pushPage(runPageCheck('flat-type-hierarchy', () => checkPageTypography(document, window)))
|
|
186
|
+
pushPage(runPageCheck('layout-rules', () => checkPageLayout(document, window)))
|
|
187
|
+
pushPage(runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document)))
|
|
188
|
+
pushPage(
|
|
189
|
+
runPageCheck('html-patterns', () =>
|
|
190
|
+
// bounce-easing and layout-transition are already covered by the regex
|
|
191
|
+
// matchers on the same markup — drop them here to avoid double-counting.
|
|
192
|
+
checkHtmlPatterns(html).filter(
|
|
193
|
+
(item) => item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Deduplicate (same antipattern + snippet within 2 lines). The text-scan
|
|
200
|
+
// layer and the cascade layer can both surface the same drift.
|
|
201
|
+
const deduped = []
|
|
202
|
+
for (const f of findings) {
|
|
203
|
+
const isDupe = deduped.some(
|
|
204
|
+
(d) => d.antipattern === f.antipattern && d.snippet === f.snippet && Math.abs((d.line || 0) - (f.line || 0)) <= 2
|
|
205
|
+
)
|
|
206
|
+
if (!isDupe) deduped.push(f)
|
|
207
|
+
}
|
|
208
|
+
return deduped
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { STATIC_ELEMENT_RULES, detectHtml }
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// ETUS Digital — Seven Design System. Detector architecture adapted from
|
|
3
|
+
// impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
|
|
4
|
+
function sanitizeScreenshotClip(clip, viewport) {
|
|
5
|
+
if (!clip) return null;
|
|
6
|
+
const x = Math.max(0, Math.floor(clip.x || 0));
|
|
7
|
+
const y = Math.max(0, Math.floor(clip.y || 0));
|
|
8
|
+
const width = Math.min(
|
|
9
|
+
Math.max(1, Math.ceil(clip.width || 0)),
|
|
10
|
+
Math.max(1, viewport?.width || 1600),
|
|
11
|
+
);
|
|
12
|
+
const height = Math.min(
|
|
13
|
+
Math.max(1, Math.ceil(clip.height || 0)),
|
|
14
|
+
320,
|
|
15
|
+
);
|
|
16
|
+
if (width < 1 || height < 1) return null;
|
|
17
|
+
return { x, y, width, height };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
|
|
21
|
+
return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
|
|
22
|
+
const loadImage = (base64) => new Promise((resolve, reject) => {
|
|
23
|
+
const img = new Image();
|
|
24
|
+
img.onload = () => resolve(img);
|
|
25
|
+
img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
|
|
26
|
+
img.src = `data:image/png;base64,${base64}`;
|
|
27
|
+
});
|
|
28
|
+
const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
|
|
29
|
+
const width = Math.min(before.width, after.width);
|
|
30
|
+
const height = Math.min(before.height, after.height);
|
|
31
|
+
if (width < 1 || height < 1) return null;
|
|
32
|
+
|
|
33
|
+
const canvas = document.createElement('canvas');
|
|
34
|
+
canvas.width = width;
|
|
35
|
+
canvas.height = height;
|
|
36
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
37
|
+
if (!ctx) return null;
|
|
38
|
+
|
|
39
|
+
ctx.drawImage(before, 0, 0, width, height);
|
|
40
|
+
const beforePixels = ctx.getImageData(0, 0, width, height).data;
|
|
41
|
+
ctx.clearRect(0, 0, width, height);
|
|
42
|
+
ctx.drawImage(after, 0, 0, width, height);
|
|
43
|
+
const afterPixels = ctx.getImageData(0, 0, width, height).data;
|
|
44
|
+
|
|
45
|
+
const luminance = ({ r, g, b }) => {
|
|
46
|
+
const convert = c => {
|
|
47
|
+
const v = c / 255;
|
|
48
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
49
|
+
};
|
|
50
|
+
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
|
|
51
|
+
};
|
|
52
|
+
const ratio = (a, b) => {
|
|
53
|
+
const l1 = luminance(a);
|
|
54
|
+
const l2 = luminance(b);
|
|
55
|
+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
|
|
59
|
+
? {
|
|
60
|
+
r: candidate.textColor.r,
|
|
61
|
+
g: candidate.textColor.g,
|
|
62
|
+
b: candidate.textColor.b,
|
|
63
|
+
}
|
|
64
|
+
: null;
|
|
65
|
+
const ratios = [];
|
|
66
|
+
let glyphPixels = 0;
|
|
67
|
+
let strongestDelta = 0;
|
|
68
|
+
for (let i = 0; i < beforePixels.length; i += 4) {
|
|
69
|
+
const delta = Math.abs(beforePixels[i] - afterPixels[i])
|
|
70
|
+
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
|
|
71
|
+
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
|
|
72
|
+
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
|
|
73
|
+
strongestDelta = Math.max(strongestDelta, delta);
|
|
74
|
+
if (delta < 10) continue;
|
|
75
|
+
glyphPixels++;
|
|
76
|
+
const fg = cssTextColor || {
|
|
77
|
+
r: beforePixels[i],
|
|
78
|
+
g: beforePixels[i + 1],
|
|
79
|
+
b: beforePixels[i + 2],
|
|
80
|
+
};
|
|
81
|
+
const bg = {
|
|
82
|
+
r: afterPixels[i],
|
|
83
|
+
g: afterPixels[i + 1],
|
|
84
|
+
b: afterPixels[i + 2],
|
|
85
|
+
};
|
|
86
|
+
ratios.push(ratio(fg, bg));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (ratios.length < 8) {
|
|
90
|
+
return {
|
|
91
|
+
glyphPixels,
|
|
92
|
+
strongestDelta,
|
|
93
|
+
worstRatio: null,
|
|
94
|
+
p10Ratio: null,
|
|
95
|
+
medianRatio: null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ratios.sort((a, b) => a - b);
|
|
100
|
+
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
|
|
101
|
+
return {
|
|
102
|
+
glyphPixels,
|
|
103
|
+
strongestDelta,
|
|
104
|
+
worstRatio: ratios[0],
|
|
105
|
+
p10Ratio: pick(10),
|
|
106
|
+
medianRatio: pick(50),
|
|
107
|
+
};
|
|
108
|
+
}, { beforeBase64, afterBase64, candidate });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function captureVisualContrastCandidate(page, candidate, viewport) {
|
|
112
|
+
const clip = sanitizeScreenshotClip(candidate.clip, viewport);
|
|
113
|
+
if (!clip) return null;
|
|
114
|
+
|
|
115
|
+
const beforeBase64 = await page.screenshot({
|
|
116
|
+
encoding: 'base64',
|
|
117
|
+
clip,
|
|
118
|
+
captureBeyondViewport: true,
|
|
119
|
+
});
|
|
120
|
+
const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
121
|
+
const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
|
|
122
|
+
let el;
|
|
123
|
+
try {
|
|
124
|
+
el = document.querySelector(selector);
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (!el) return false;
|
|
129
|
+
let style = document.getElementById('impeccable-visual-contrast-hide-style');
|
|
130
|
+
if (!style) {
|
|
131
|
+
style = document.createElement('style');
|
|
132
|
+
style.id = 'impeccable-visual-contrast-hide-style';
|
|
133
|
+
style.textContent = [
|
|
134
|
+
'[data-impeccable-visual-contrast-target] {',
|
|
135
|
+
' color: transparent !important;',
|
|
136
|
+
' -webkit-text-fill-color: transparent !important;',
|
|
137
|
+
' text-shadow: none !important;',
|
|
138
|
+
'}',
|
|
139
|
+
'[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
|
|
140
|
+
' background-image: none !important;',
|
|
141
|
+
'}',
|
|
142
|
+
].join('\n');
|
|
143
|
+
document.head.appendChild(style);
|
|
144
|
+
}
|
|
145
|
+
el.setAttribute('data-impeccable-visual-contrast-target', token);
|
|
146
|
+
if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
|
|
147
|
+
return true;
|
|
148
|
+
}, {
|
|
149
|
+
selector: candidate.selector,
|
|
150
|
+
token,
|
|
151
|
+
backgroundClipText: candidate.backgroundClipText,
|
|
152
|
+
});
|
|
153
|
+
if (!applied) return null;
|
|
154
|
+
|
|
155
|
+
let afterBase64;
|
|
156
|
+
try {
|
|
157
|
+
afterBase64 = await page.screenshot({
|
|
158
|
+
encoding: 'base64',
|
|
159
|
+
clip,
|
|
160
|
+
captureBeyondViewport: true,
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
await page.evaluate(({ selector }) => {
|
|
164
|
+
try {
|
|
165
|
+
const el = document.querySelector(selector);
|
|
166
|
+
if (el) {
|
|
167
|
+
el.removeAttribute('data-impeccable-visual-contrast-target');
|
|
168
|
+
el.removeAttribute('data-impeccable-bgclip-text');
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Ignore invalid or stale selectors during cleanup.
|
|
172
|
+
}
|
|
173
|
+
}, { selector: candidate.selector }).catch(() => {});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
|
|
177
|
+
if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
|
|
178
|
+
const measuredRatio = metrics.p10Ratio;
|
|
179
|
+
if (measuredRatio >= candidate.threshold) return null;
|
|
180
|
+
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
|
|
181
|
+
const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
|
|
182
|
+
return {
|
|
183
|
+
id: 'low-contrast',
|
|
184
|
+
snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export {
|
|
189
|
+
sanitizeScreenshotClip,
|
|
190
|
+
compareScreenshotContrast,
|
|
191
|
+
captureVisualContrastCandidate,
|
|
192
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// ETUS Digital — Seven Design System. Detector architecture adapted from
|
|
3
|
+
// impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
|
|
4
|
+
|
|
5
|
+
import { getAntipattern } from './registry/seven-antipatterns.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a normalized finding record. Every engine (regex, static-html,
|
|
9
|
+
* browser) produces findings through this helper so the shape is identical
|
|
10
|
+
* regardless of detection path.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ antipattern: string, name: string, description: string,
|
|
13
|
+
* category: string, severity: string, file: string, line: number,
|
|
14
|
+
* snippet: string }}
|
|
15
|
+
*/
|
|
16
|
+
export function finding(id, filePath, snippet, line = 0) {
|
|
17
|
+
const ap = getAntipattern(id)
|
|
18
|
+
return {
|
|
19
|
+
antipattern: id,
|
|
20
|
+
name: ap ? ap.name : id,
|
|
21
|
+
description: ap ? ap.description : '',
|
|
22
|
+
category: ap ? ap.category : 'quality',
|
|
23
|
+
severity: ap && ap.severity ? ap.severity : 'warning',
|
|
24
|
+
file: filePath,
|
|
25
|
+
line,
|
|
26
|
+
snippet,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// ETUS Digital — Seven Design System. Detector architecture adapted from
|
|
3
|
+
// impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
|
|
8
|
+
// ─── File walker ────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export const SKIP_DIRS = new Set([
|
|
11
|
+
'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', '.output',
|
|
12
|
+
'.svelte-kit', '__pycache__', '.turbo', '.vercel', '.netlify', '.cache',
|
|
13
|
+
'coverage', '.worktrees',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
// Markdown is intentionally excluded: docs legitimately contain code examples
|
|
17
|
+
// of anti-patterns (the "do not do this" kind), which the detector must not
|
|
18
|
+
// flag. The detector scans code, not documentation.
|
|
19
|
+
export const SCANNABLE_EXTENSIONS = new Set([
|
|
20
|
+
'.html', '.htm', '.css', '.scss', '.less',
|
|
21
|
+
'.jsx', '.tsx', '.js', '.ts', '.mjs', '.cjs',
|
|
22
|
+
'.vue', '.svelte', '.astro',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export const HTML_EXTENSIONS = new Set(['.html', '.htm'])
|
|
26
|
+
|
|
27
|
+
// Story and test files are scaffolding, not shipping UI. Their string
|
|
28
|
+
// literals legitimately carry demo prose and intentional anti-pattern
|
|
29
|
+
// examples — the detector targets production code, so the walker skips them.
|
|
30
|
+
const NON_SHIPPING_FILE_RE = /\.(?:stories|story|test|spec)\.[a-z]+$/i
|
|
31
|
+
|
|
32
|
+
export function isShippingSourceFile(name) {
|
|
33
|
+
return !NON_SHIPPING_FILE_RE.test(name)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function walkDir(dir, maxFiles = 50000) {
|
|
37
|
+
const files = []
|
|
38
|
+
const stack = [dir]
|
|
39
|
+
while (stack.length > 0 && files.length < maxFiles) {
|
|
40
|
+
const current = stack.pop()
|
|
41
|
+
let entries
|
|
42
|
+
try {
|
|
43
|
+
entries = fs.readdirSync(current, { withFileTypes: true })
|
|
44
|
+
} catch {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
49
|
+
const full = path.join(current, entry.name)
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
stack.push(full)
|
|
52
|
+
} else if (
|
|
53
|
+
entry.isFile() &&
|
|
54
|
+
SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()) &&
|
|
55
|
+
isShippingSourceFile(entry.name)
|
|
56
|
+
) {
|
|
57
|
+
files.push(full)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return files
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PACKAGE_NAME_CACHE = new Map()
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Walk up from `filePath` until a package.json is found. Returns its `name`
|
|
68
|
+
* field, or null. Used for the package-aware rule exemption: inside the Seven
|
|
69
|
+
* monorepo this resolves `@etus/ui`; inside a consumer it resolves files under
|
|
70
|
+
* `node_modules/@etus/ui/`.
|
|
71
|
+
*/
|
|
72
|
+
export function findOwningPackageName(filePath) {
|
|
73
|
+
let current = path.dirname(path.resolve(filePath))
|
|
74
|
+
while (current) {
|
|
75
|
+
const cached = PACKAGE_NAME_CACHE.get(current)
|
|
76
|
+
if (cached !== undefined) return cached
|
|
77
|
+
const candidate = path.join(current, 'package.json')
|
|
78
|
+
let exists = false
|
|
79
|
+
try {
|
|
80
|
+
exists = fs.statSync(candidate).isFile()
|
|
81
|
+
} catch {
|
|
82
|
+
exists = false
|
|
83
|
+
}
|
|
84
|
+
if (exists) {
|
|
85
|
+
let name = null
|
|
86
|
+
try {
|
|
87
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'))
|
|
88
|
+
name = typeof pkg.name === 'string' ? pkg.name : null
|
|
89
|
+
} catch {
|
|
90
|
+
name = null
|
|
91
|
+
}
|
|
92
|
+
PACKAGE_NAME_CACHE.set(current, name)
|
|
93
|
+
return name
|
|
94
|
+
}
|
|
95
|
+
const parent = path.dirname(current)
|
|
96
|
+
if (parent === current) break
|
|
97
|
+
current = parent
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Import graph (multi-file awareness) ────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function resolveImport(specifier, fromDir, fileSet) {
|
|
105
|
+
if (!/^[./]/.test(specifier)) return null
|
|
106
|
+
const base = path.resolve(fromDir, specifier)
|
|
107
|
+
if (fileSet.has(base)) return base
|
|
108
|
+
for (const ext of SCANNABLE_EXTENSIONS) {
|
|
109
|
+
if (fileSet.has(base + ext)) return base + ext
|
|
110
|
+
}
|
|
111
|
+
for (const ext of SCANNABLE_EXTENSIONS) {
|
|
112
|
+
const indexFile = path.join(base, 'index' + ext)
|
|
113
|
+
if (fileSet.has(indexFile)) return indexFile
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildImportGraph(files) {
|
|
119
|
+
const fileSet = new Set(files)
|
|
120
|
+
const graph = new Map()
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
let content
|
|
123
|
+
try {
|
|
124
|
+
content = fs.readFileSync(file, 'utf-8')
|
|
125
|
+
} catch {
|
|
126
|
+
graph.set(file, new Set())
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
const dir = path.dirname(file)
|
|
130
|
+
const imports = new Set()
|
|
131
|
+
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g
|
|
132
|
+
let m
|
|
133
|
+
while ((m = esRe.exec(content)) !== null) {
|
|
134
|
+
const resolved = resolveImport(m[1], dir, fileSet)
|
|
135
|
+
if (resolved) imports.add(resolved)
|
|
136
|
+
}
|
|
137
|
+
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g
|
|
138
|
+
while ((m = cssRe.exec(content)) !== null) {
|
|
139
|
+
const resolved = resolveImport(m[1], dir, fileSet)
|
|
140
|
+
if (resolved) imports.add(resolved)
|
|
141
|
+
}
|
|
142
|
+
graph.set(file, imports)
|
|
143
|
+
}
|
|
144
|
+
return graph
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Framework dev server detection ─────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export const FRAMEWORK_CONFIGS = [
|
|
150
|
+
{ name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { header: 'x-powered-by', value: /next/i } },
|
|
151
|
+
{ name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { header: 'x-sveltekit-page', value: null } },
|
|
152
|
+
{ name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
|
|
153
|
+
{ name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { body: /@vite\/client/ } },
|
|
154
|
+
{ name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { body: /astro/i } },
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
export function detectFrameworkConfig(dir) {
|
|
158
|
+
let entries
|
|
159
|
+
try {
|
|
160
|
+
entries = fs.readdirSync(dir)
|
|
161
|
+
} catch {
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
const entrySet = new Set(entries)
|
|
165
|
+
for (const cfg of FRAMEWORK_CONFIGS) {
|
|
166
|
+
const match = cfg.files.find((f) => entrySet.has(f))
|
|
167
|
+
if (!match) continue
|
|
168
|
+
const configPath = path.join(dir, match)
|
|
169
|
+
let port = cfg.defaultPort
|
|
170
|
+
try {
|
|
171
|
+
const portMatch = fs.readFileSync(configPath, 'utf-8').match(cfg.portRe)
|
|
172
|
+
if (portMatch) port = parseInt(portMatch[1], 10)
|
|
173
|
+
} catch {
|
|
174
|
+
// use default
|
|
175
|
+
}
|
|
176
|
+
return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint }
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Probe whether a dev server is listening; optionally verify framework. */
|
|
182
|
+
export async function isPortListening(port, fingerprint = null) {
|
|
183
|
+
if (!fingerprint) {
|
|
184
|
+
const net = await import('node:net')
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
const sock = net.default.createConnection({ port, host: '127.0.0.1' })
|
|
187
|
+
sock.setTimeout(500)
|
|
188
|
+
sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }) })
|
|
189
|
+
sock.on('error', () => resolve({ listening: false }))
|
|
190
|
+
sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }) })
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
const timeout = setTimeout(() => controller.abort(), 2000)
|
|
196
|
+
const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' })
|
|
197
|
+
clearTimeout(timeout)
|
|
198
|
+
if (fingerprint.header) {
|
|
199
|
+
const val = res.headers.get(fingerprint.header)
|
|
200
|
+
if (val && (!fingerprint.value || fingerprint.value.test(val))) {
|
|
201
|
+
return { listening: true, matched: true }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (fingerprint.body) {
|
|
205
|
+
const body = await res.text()
|
|
206
|
+
if (fingerprint.body.test(body)) return { listening: true, matched: true }
|
|
207
|
+
}
|
|
208
|
+
return { listening: true, matched: false }
|
|
209
|
+
} catch {
|
|
210
|
+
return { listening: false }
|
|
211
|
+
}
|
|
212
|
+
}
|