similarbuild 0.1.0
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/CHANGELOG.md +110 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/install.js +256 -0
- package/lib/copy-templates.mjs +52 -0
- package/lib/install-deps.mjs +62 -0
- package/lib/prompt-config.mjs +83 -0
- package/lib/verify-env.mjs +19 -0
- package/package.json +63 -0
- package/scripts/sync-templates.mjs +71 -0
- package/templates/commands/build-page.md +490 -0
- package/templates/commands/build-site.md +548 -0
- package/templates/commands/clip-section.md +519 -0
- package/templates/memory/anti-patterns.md +212 -0
- package/templates/memory/design-knowledge.md +225 -0
- package/templates/memory/fixes.md +163 -0
- package/templates/memory/patterns.md +681 -0
- package/templates/presets/shopify-section.yaml +51 -0
- package/templates/presets/wp-elementor.yaml +49 -0
- package/templates/reports/fixtures/mock-run-1.json +115 -0
- package/templates/reports/fixtures/mock-run-2.json +72 -0
- package/templates/reports/report-renderer.mjs +218 -0
- package/templates/reports/report-template.html +571 -0
- package/templates/skills/sb-build-shopify/SKILL.md +104 -0
- package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
- package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
- package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
- package/templates/skills/sb-build-wp/SKILL.md +83 -0
- package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
- package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
- package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
- package/templates/skills/sb-compare-visual/SKILL.md +121 -0
- package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
- package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
- package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
- package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
- package/templates/skills/sb-extract-assets/SKILL.md +112 -0
- package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
- package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
- package/templates/skills/sb-inspect-live/SKILL.md +105 -0
- package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
- package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
- package/templates/skills/sb-review-checks/SKILL.md +113 -0
- package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
- package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
- package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
- package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
- package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
- package/templates/skills/sb-tweak/SKILL.md +130 -0
- package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
- package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
- package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
- package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
- package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
- package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
- package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
- package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
- package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
- package/templates/skills/sb-validate-render/SKILL.md +120 -0
- package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
- package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// anti-patterns.mjs — Pure detection functions for the canon visual anti-patterns
|
|
2
|
+
// from `<plugin>/memory/anti-patterns.md` (the "bootstrap" list). Each check is
|
|
3
|
+
// a pure function `(ctx) => violations[]` with no I/O and no global state, so
|
|
4
|
+
// every rule is unit-testable in isolation.
|
|
5
|
+
//
|
|
6
|
+
// `ctx` shape:
|
|
7
|
+
// { html: string, css: string, $: cheerio.CheerioAPI, preset: 'wp-elementor'|'shopify-section', styleBlocks: [{ css, htmlOffset }] }
|
|
8
|
+
//
|
|
9
|
+
// Each violation:
|
|
10
|
+
// { group: 'anti-pattern', id: '#1', location: '...', issue: '...',
|
|
11
|
+
// candidateFix: '...', severity: 'high'|'medium'|'low' }
|
|
12
|
+
//
|
|
13
|
+
// The candidateFix string is the contract with sb-build-{wp,shopify}: it must be
|
|
14
|
+
// specific enough to apply programmatically. A generic "improve specificity" is
|
|
15
|
+
// useless — a builder can't act on it. Every fix names the selector, attribute,
|
|
16
|
+
// or property it wants changed.
|
|
17
|
+
|
|
18
|
+
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 }
|
|
19
|
+
|
|
20
|
+
export function sortBySeverity(violations) {
|
|
21
|
+
return [...violations].sort((a, b) => {
|
|
22
|
+
const sa = SEVERITY_ORDER[a.severity] ?? 99
|
|
23
|
+
const sb = SEVERITY_ORDER[b.severity] ?? 99
|
|
24
|
+
return sa - sb
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Find which line `index` falls on in `source`. 1-based.
|
|
29
|
+
export function lineOf(source, index) {
|
|
30
|
+
if (index < 0) return 0
|
|
31
|
+
let line = 1
|
|
32
|
+
for (let i = 0; i < index && i < source.length; i++) {
|
|
33
|
+
if (source.charCodeAt(i) === 10) line++
|
|
34
|
+
}
|
|
35
|
+
return line
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Walk all CSS rule blocks `selector { ...decls... }` from the combined CSS.
|
|
39
|
+
// Returns { selector, body, openIndex } per rule (open brace index in source).
|
|
40
|
+
// Skips at-rules' own bodies but recurses into them so nested rules are visited.
|
|
41
|
+
export function walkRules(css) {
|
|
42
|
+
const rules = []
|
|
43
|
+
walk(css, 0, css.length, rules)
|
|
44
|
+
return rules
|
|
45
|
+
|
|
46
|
+
function walk(src, start, end, out) {
|
|
47
|
+
let i = start
|
|
48
|
+
while (i < end) {
|
|
49
|
+
// Comment
|
|
50
|
+
if (src[i] === '/' && src[i + 1] === '*') {
|
|
51
|
+
const close = src.indexOf('*/', i + 2)
|
|
52
|
+
if (close === -1) return
|
|
53
|
+
i = close + 2
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
// Whitespace
|
|
57
|
+
if (/\s/.test(src[i])) { i++; continue }
|
|
58
|
+
// At-rule
|
|
59
|
+
if (src[i] === '@') {
|
|
60
|
+
const semi = findStmtEnd(src, i, end)
|
|
61
|
+
if (semi.kind === 'block') {
|
|
62
|
+
// recurse into the at-rule body so nested rules are picked up
|
|
63
|
+
walk(src, semi.bodyStart + 1, semi.bodyEnd, out)
|
|
64
|
+
i = semi.bodyEnd + 1
|
|
65
|
+
} else {
|
|
66
|
+
i = semi.semiIndex + 1
|
|
67
|
+
}
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
// Rule
|
|
71
|
+
const open = findOpenBrace(src, i, end)
|
|
72
|
+
if (open === -1) return
|
|
73
|
+
const close = findMatchingBrace(src, open)
|
|
74
|
+
if (close === -1) return
|
|
75
|
+
const selector = src.slice(i, open).trim()
|
|
76
|
+
const body = src.slice(open + 1, close)
|
|
77
|
+
out.push({ selector, body, openIndex: open })
|
|
78
|
+
i = close + 1
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findOpenBrace(src, start, end) {
|
|
84
|
+
for (let i = start; i < end; i++) {
|
|
85
|
+
if (src[i] === '{') return i
|
|
86
|
+
if (src[i] === ';') return -1 // declaration-without-rule (shouldn't happen at this level)
|
|
87
|
+
}
|
|
88
|
+
return -1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findMatchingBrace(src, openIndex) {
|
|
92
|
+
let depth = 1
|
|
93
|
+
for (let i = openIndex + 1; i < src.length; i++) {
|
|
94
|
+
if (src[i] === '/' && src[i + 1] === '*') {
|
|
95
|
+
const close = src.indexOf('*/', i + 2)
|
|
96
|
+
if (close === -1) return -1
|
|
97
|
+
i = close + 1
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (src[i] === '{') depth++
|
|
101
|
+
else if (src[i] === '}') {
|
|
102
|
+
depth--
|
|
103
|
+
if (depth === 0) return i
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return -1
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findStmtEnd(src, start, end) {
|
|
110
|
+
for (let i = start; i < end; i++) {
|
|
111
|
+
if (src[i] === '{') {
|
|
112
|
+
const close = findMatchingBrace(src, i)
|
|
113
|
+
return { kind: 'block', bodyStart: i, bodyEnd: close }
|
|
114
|
+
}
|
|
115
|
+
if (src[i] === ';') return { kind: 'stmt', semiIndex: i }
|
|
116
|
+
}
|
|
117
|
+
return { kind: 'stmt', semiIndex: end - 1 }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── #1 — 100vh in WP/Elementor (clips on mobile) ───────────────────────────
|
|
121
|
+
export function check100vh(ctx) {
|
|
122
|
+
if (ctx.preset !== 'wp-elementor') return []
|
|
123
|
+
const violations = []
|
|
124
|
+
for (const block of ctx.styleBlocks) {
|
|
125
|
+
let m
|
|
126
|
+
const re = /\b100vh\b/g
|
|
127
|
+
while ((m = re.exec(block.css)) !== null) {
|
|
128
|
+
violations.push({
|
|
129
|
+
group: 'anti-pattern',
|
|
130
|
+
id: '#1',
|
|
131
|
+
location: `style block line ${lineOf(block.css, m.index)}`,
|
|
132
|
+
issue: '100vh inside Elementor clips on mobile (browser chrome eats viewport)',
|
|
133
|
+
candidateFix: 'replace `100vh` with `aspect-ratio: 9 / 16; max-height: 700px` on the hero container',
|
|
134
|
+
severity: 'high',
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return violations
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── #2 — <img> with height: 100% in hero (theme overrides win) ────────────
|
|
142
|
+
// Heuristic: any `<img>` element under a class containing "hero" with an
|
|
143
|
+
// inline `style` declaring `height: 100%` (or `height:100%`). Catches the
|
|
144
|
+
// most common variant where the build tries to fill a hero box with <img>.
|
|
145
|
+
export function checkImgHeight100Hero(ctx) {
|
|
146
|
+
const { $ } = ctx
|
|
147
|
+
const violations = []
|
|
148
|
+
$('img[style]').each((_, el) => {
|
|
149
|
+
const $el = $(el)
|
|
150
|
+
const style = String($el.attr('style') || '')
|
|
151
|
+
if (!/height\s*:\s*100\s*%/i.test(style)) return
|
|
152
|
+
if (!hasHeroAncestor($, $el)) return
|
|
153
|
+
const cls = $el.attr('class') || ''
|
|
154
|
+
violations.push({
|
|
155
|
+
group: 'anti-pattern',
|
|
156
|
+
id: '#2',
|
|
157
|
+
location: cls ? `<img class="${cls}">` : '<img> with inline height:100%',
|
|
158
|
+
issue: '<img> with height:100% in hero — Elementor injects `img { height: auto !important }` and clobbers it',
|
|
159
|
+
candidateFix: 'replace the <img> hero with a `background-image` on the hero container; keep alt text on a sibling visually-hidden <img> if needed',
|
|
160
|
+
severity: 'high',
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
return violations
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hasHeroAncestor($, $el) {
|
|
167
|
+
let cur = $el
|
|
168
|
+
for (let i = 0; i < 8 && cur.length; i++) {
|
|
169
|
+
const cls = String(cur.attr('class') || '').toLowerCase()
|
|
170
|
+
if (/(^|[\s_-])hero([\s_-]|$)/.test(cls)) return true
|
|
171
|
+
cur = cur.parent()
|
|
172
|
+
}
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── #3 — Round-number opacity (likely guessed, not measured) ───────────────
|
|
177
|
+
// Flags `opacity: 0.3 | 0.5 | 0.7` (the round eyeballs). Other values are
|
|
178
|
+
// probably real readings or token-derived. Severity low — this is a nudge.
|
|
179
|
+
export function checkRoundOpacity(ctx) {
|
|
180
|
+
const violations = []
|
|
181
|
+
for (const block of ctx.styleBlocks) {
|
|
182
|
+
const re = /opacity\s*:\s*(0?\.[357])\b/g
|
|
183
|
+
let m
|
|
184
|
+
while ((m = re.exec(block.css)) !== null) {
|
|
185
|
+
violations.push({
|
|
186
|
+
group: 'anti-pattern',
|
|
187
|
+
id: '#3',
|
|
188
|
+
location: `style block line ${lineOf(block.css, m.index)}`,
|
|
189
|
+
issue: `opacity: ${m[1]} looks eyeballed — round values rarely match the source`,
|
|
190
|
+
candidateFix: `verify the exact alpha against inspection.tokens; if measured, leave a comment \`/* alpha from inspection */\` so the next reviewer doesn't re-flag it`,
|
|
191
|
+
severity: 'low',
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return violations
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── #4 — `a { color: inherit }` in scoped CSS ──────────────────────────────
|
|
199
|
+
// Inherits whatever ancestor color leaked through. Specifically problematic
|
|
200
|
+
// when the ancestor is themed (button color via parent, link color via theme).
|
|
201
|
+
export function checkAnchorColorInherit(ctx) {
|
|
202
|
+
const violations = []
|
|
203
|
+
for (const rule of walkRules(ctx.css)) {
|
|
204
|
+
const sel = rule.selector
|
|
205
|
+
// Match selectors that target <a> as the rightmost simple selector
|
|
206
|
+
if (!/(?:^|[\s>+~])a(?:[.:#\[][^,\s]*)?\s*$/.test(sel)) continue
|
|
207
|
+
if (!/color\s*:\s*inherit\b/i.test(rule.body)) continue
|
|
208
|
+
violations.push({
|
|
209
|
+
group: 'anti-pattern',
|
|
210
|
+
id: '#4',
|
|
211
|
+
location: `selector \`${sel}\``,
|
|
212
|
+
issue: '`a { color: inherit }` clobbers brand color when the ancestor is themed',
|
|
213
|
+
candidateFix: `replace \`color: inherit\` in \`${sel}\` with a literal hex from inspection.tokens (e.g. \`color: #d8112a\`)`,
|
|
214
|
+
severity: 'medium',
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
return violations
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── #5 — <button> decorative without defensive specificity ─────────────────
|
|
221
|
+
// Heuristic: a rule whose selector ends in `.{token}` (single class) and styles
|
|
222
|
+
// background/color/font on a button-shaped pattern (selector contains "button"
|
|
223
|
+
// or "btn" or "cta"). Real defensive specificity is ancestor-chained, e.g.
|
|
224
|
+
// `.scope .scope__cta.scope__cta`.
|
|
225
|
+
export function checkButtonNoChain(ctx) {
|
|
226
|
+
const violations = []
|
|
227
|
+
for (const rule of walkRules(ctx.css)) {
|
|
228
|
+
const sel = rule.selector
|
|
229
|
+
if (!/(button|btn|cta)/i.test(sel)) continue
|
|
230
|
+
// Defensive specificity = ancestor present (descendant combinator) OR class chain
|
|
231
|
+
const looksDefensive = /\s/.test(sel.trim()) || /\.[\w-]+\.[\w-]+/.test(sel)
|
|
232
|
+
if (looksDefensive) continue
|
|
233
|
+
// Must actually style something theme will fight over
|
|
234
|
+
if (!/(background|color|font-)/i.test(rule.body)) continue
|
|
235
|
+
violations.push({
|
|
236
|
+
group: 'anti-pattern',
|
|
237
|
+
id: '#5',
|
|
238
|
+
location: `selector \`${sel}\``,
|
|
239
|
+
issue: 'button-like selector without ancestor scope or class-chain — theme `.elementor *` rules will win',
|
|
240
|
+
candidateFix: `chain the scope as ancestor: rewrite \`${sel}\` as \`.{scope} ${sel}${sel}\` (double the modifier class to raise specificity past .elementor *)`,
|
|
241
|
+
severity: 'high',
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
return violations
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── #5b — `!important` without ancestor prefix ─────────────────────────────
|
|
248
|
+
// Useless on its own — theme rules carry both ancestor specificity AND
|
|
249
|
+
// !important. The build needs both too.
|
|
250
|
+
export function checkImportantNoAncestor(ctx) {
|
|
251
|
+
const violations = []
|
|
252
|
+
for (const rule of walkRules(ctx.css)) {
|
|
253
|
+
const body = rule.body
|
|
254
|
+
if (!/!\s*important/i.test(body)) continue
|
|
255
|
+
const sel = rule.selector
|
|
256
|
+
// Ancestor present means: there's whitespace combinator inside the selector
|
|
257
|
+
// (excluding child/+/~ — those still satisfy "has ancestor" if anything is
|
|
258
|
+
// before the rightmost simple selector). Concretely: split on ',' and check
|
|
259
|
+
// each branch has an internal combinator.
|
|
260
|
+
const branches = splitTopLevel(sel, ',').map((s) => s.trim()).filter(Boolean)
|
|
261
|
+
const allHaveAncestor = branches.every((b) => /[\s>+~]/.test(b.replace(/^[.#:\[\w-]+/, '')))
|
|
262
|
+
if (allHaveAncestor) continue
|
|
263
|
+
violations.push({
|
|
264
|
+
group: 'anti-pattern',
|
|
265
|
+
id: '#5b',
|
|
266
|
+
location: `selector \`${sel}\``,
|
|
267
|
+
issue: '`!important` without an ancestor in the selector — theme has both ancestor + !important so this loses anyway',
|
|
268
|
+
candidateFix: `prefix \`${sel}\` with the scope as ancestor: rewrite as \`.{scope} ${sel}\` so the chained-scope specificity matches the theme's ancestor-prefixed !important rules`,
|
|
269
|
+
severity: 'high',
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
return violations
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function splitTopLevel(str, ch) {
|
|
276
|
+
const out = []
|
|
277
|
+
let depth = 0
|
|
278
|
+
let start = 0
|
|
279
|
+
for (let i = 0; i < str.length; i++) {
|
|
280
|
+
const c = str[i]
|
|
281
|
+
if (c === '(' || c === '[') depth++
|
|
282
|
+
else if (c === ')' || c === ']') depth--
|
|
283
|
+
else if (c === ch && depth === 0) {
|
|
284
|
+
out.push(str.slice(start, i))
|
|
285
|
+
start = i + 1
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
out.push(str.slice(start))
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── #6 — Overlap-image modeled as a sibling box on the wrapper ─────────────
|
|
293
|
+
// Should be a `::before` on the SECTION. Heuristic: a `::before` rule whose
|
|
294
|
+
// selector targets a wrapper class (matches /wrap|wrapper|container/) AND the
|
|
295
|
+
// declaration uses `position: absolute` with edge insets that look full-bleed.
|
|
296
|
+
export function checkOverlapBoxInWrapper(ctx) {
|
|
297
|
+
const violations = []
|
|
298
|
+
for (const rule of walkRules(ctx.css)) {
|
|
299
|
+
if (!/::before\b/.test(rule.selector)) continue
|
|
300
|
+
if (!/(wrap|wrapper|container|inner)/i.test(rule.selector)) continue
|
|
301
|
+
if (!/position\s*:\s*absolute/i.test(rule.body)) continue
|
|
302
|
+
if (!/(inset|top|right|bottom|left)\s*:/i.test(rule.body)) continue
|
|
303
|
+
violations.push({
|
|
304
|
+
group: 'anti-pattern',
|
|
305
|
+
id: '#6',
|
|
306
|
+
location: `selector \`${rule.selector}\``,
|
|
307
|
+
issue: '`::before` on a wrapper instead of the section — overlap bands belong to the section so they sit behind the image, not the column',
|
|
308
|
+
candidateFix: `move the \`::before\` from \`${rule.selector}\` to the section selector (the outermost scope element); set \`position: relative\` on the section and \`z-index: -1\` on the ::before`,
|
|
309
|
+
severity: 'medium',
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
return violations
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── #8 — Inline SVG impostor where a raster <img> belongs ─────────────────
|
|
316
|
+
// Heuristic: large viewBox (any dim ≥ 400) + ≥2 `<path>` children + nearby
|
|
317
|
+
// "product" / "photo" / hero context. SVGs that came from the source as icons
|
|
318
|
+
// (small viewBox) are fine — those are the legitimate use case.
|
|
319
|
+
export function checkSvgRasterImpostor(ctx) {
|
|
320
|
+
const { $ } = ctx
|
|
321
|
+
const violations = []
|
|
322
|
+
$('svg').each((_, el) => {
|
|
323
|
+
const $el = $(el)
|
|
324
|
+
const vb = String($el.attr('viewBox') || '').trim()
|
|
325
|
+
const m = vb.match(/-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/)
|
|
326
|
+
if (!m) return
|
|
327
|
+
const w = parseFloat(m[1])
|
|
328
|
+
const h = parseFloat(m[2])
|
|
329
|
+
if (Math.max(w, h) < 400) return
|
|
330
|
+
const paths = $el.find('path').length
|
|
331
|
+
if (paths < 2) return
|
|
332
|
+
const ctxLower = (
|
|
333
|
+
String($el.attr('class') || '') + ' ' +
|
|
334
|
+
String($el.parent().attr('class') || '') + ' ' +
|
|
335
|
+
String($el.closest('section,figure,article').attr('class') || '')
|
|
336
|
+
).toLowerCase()
|
|
337
|
+
if (!/(product|photo|hero|image|card|variant)/.test(ctxLower)) return
|
|
338
|
+
violations.push({
|
|
339
|
+
group: 'anti-pattern',
|
|
340
|
+
id: '#8',
|
|
341
|
+
location: `<svg viewBox="${vb}"> with ${paths} <path> children`,
|
|
342
|
+
issue: 'inline SVG where a real raster image belongs — SVGs reproduced from photos look "off" and miss real-world detail',
|
|
343
|
+
candidateFix: `replace this <svg> with an <img src="{assetsMap[originalUrl].localPath}" alt="..." loading="lazy"> using the actual asset from sb-extract-assets`,
|
|
344
|
+
severity: 'medium',
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
return violations
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Public registry — orchestrator iterates this so adding a check is one line.
|
|
351
|
+
export const ANTI_PATTERN_CHECKS = [
|
|
352
|
+
check100vh,
|
|
353
|
+
checkImgHeight100Hero,
|
|
354
|
+
checkRoundOpacity,
|
|
355
|
+
checkAnchorColorInherit,
|
|
356
|
+
checkButtonNoChain,
|
|
357
|
+
checkImportantNoAncestor,
|
|
358
|
+
checkOverlapBoxInWrapper,
|
|
359
|
+
checkSvgRasterImpostor,
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
export function runAntiPatterns(ctx) {
|
|
363
|
+
const out = []
|
|
364
|
+
for (const fn of ANTI_PATTERN_CHECKS) {
|
|
365
|
+
try {
|
|
366
|
+
out.push(...fn(ctx))
|
|
367
|
+
} catch (err) {
|
|
368
|
+
out.push({
|
|
369
|
+
group: 'anti-pattern',
|
|
370
|
+
id: fn.name || 'unknown',
|
|
371
|
+
location: 'check',
|
|
372
|
+
issue: `check threw: ${err.message}`,
|
|
373
|
+
candidateFix: 'inspect the input file — it may be malformed HTML/CSS',
|
|
374
|
+
severity: 'low',
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return sortBySeverity(out)
|
|
379
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// cross-reference.mjs — Optional layer that combines this skill's static
|
|
2
|
+
// violations with `compareDiffs` from sb-compare-visual. The contract:
|
|
3
|
+
//
|
|
4
|
+
// - When a violation's `area`/`location` overlaps a structuredDiff's `area`,
|
|
5
|
+
// and that diff is severity high, escalate the violation to high (or keep
|
|
6
|
+
// it high) and tag it with `correlatedDiff`.
|
|
7
|
+
// - Add a synthetic violation for each high-severity diff that no static
|
|
8
|
+
// check caught — the user still needs to know about it.
|
|
9
|
+
//
|
|
10
|
+
// This is what makes sb-review-checks the engine of the auto-correct loop:
|
|
11
|
+
// the orchestrator can take the prioritized output and feed it straight back
|
|
12
|
+
// to sb-build-{wp,shopify} as `fixHints`, ranked by both static rules AND
|
|
13
|
+
// the actual visual delta.
|
|
14
|
+
//
|
|
15
|
+
// Pure function, no I/O. Tested in isolation.
|
|
16
|
+
|
|
17
|
+
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 }
|
|
18
|
+
|
|
19
|
+
// Map a structured diff's `area` to substrings we look for in violation
|
|
20
|
+
// locations. Loose, on purpose — better to over-correlate (LLM filters) than
|
|
21
|
+
// miss obvious matches.
|
|
22
|
+
const AREA_HINTS = {
|
|
23
|
+
h1: ['h1', '<h1'],
|
|
24
|
+
h2: ['h2', '<h2'],
|
|
25
|
+
h3: ['h3', '<h3'],
|
|
26
|
+
body: ['body', 'paragraph'],
|
|
27
|
+
button: ['button', 'btn', 'cta'],
|
|
28
|
+
container: ['container', 'wrapper', 'section'],
|
|
29
|
+
images: ['img', 'image', '<img', 'svg'],
|
|
30
|
+
geometry: ['hero', 'section', 'overflow', 'height'],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function locationMatchesArea(location, area) {
|
|
34
|
+
if (!location || !area) return false
|
|
35
|
+
const hints = AREA_HINTS[area] || [area]
|
|
36
|
+
const lc = String(location).toLowerCase()
|
|
37
|
+
return hints.some((h) => lc.includes(h))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function violationKey(v) {
|
|
41
|
+
return `${v.group}|${v.id}|${v.location}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function applyCrossReference(violations, compareDiffs) {
|
|
45
|
+
if (!Array.isArray(compareDiffs) || compareDiffs.length === 0) {
|
|
46
|
+
return [...violations]
|
|
47
|
+
}
|
|
48
|
+
const highDiffs = compareDiffs.filter((d) => d && d.severity === 'high')
|
|
49
|
+
if (highDiffs.length === 0) return [...violations]
|
|
50
|
+
|
|
51
|
+
const annotated = violations.map((v) => {
|
|
52
|
+
const matches = highDiffs.filter((d) => locationMatchesArea(v.location, d.area))
|
|
53
|
+
if (matches.length === 0) return { ...v }
|
|
54
|
+
return {
|
|
55
|
+
...v,
|
|
56
|
+
severity: 'high',
|
|
57
|
+
correlatedDiff: {
|
|
58
|
+
area: matches[0].area,
|
|
59
|
+
issue: matches[0].issue,
|
|
60
|
+
deltaPx: matches[0].deltaPx,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Diffs that no violation covered → synthesize one. The orchestrator wants
|
|
66
|
+
// the full picture in one list, not "look at violations AND compareDiffs."
|
|
67
|
+
const seenAreas = new Set(
|
|
68
|
+
annotated
|
|
69
|
+
.filter((v) => v.correlatedDiff)
|
|
70
|
+
.map((v) => v.correlatedDiff.area),
|
|
71
|
+
)
|
|
72
|
+
for (const diff of highDiffs) {
|
|
73
|
+
if (seenAreas.has(diff.area)) continue
|
|
74
|
+
annotated.push({
|
|
75
|
+
group: 'visual-diff',
|
|
76
|
+
id: `diff-${diff.area}`,
|
|
77
|
+
location: diff.area,
|
|
78
|
+
issue: diff.issue,
|
|
79
|
+
candidateFix: synthesizeFixFromDiff(diff),
|
|
80
|
+
severity: 'high',
|
|
81
|
+
correlatedDiff: { area: diff.area, issue: diff.issue, deltaPx: diff.deltaPx },
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return annotated
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// When a visual diff has no static violation backing it, generate a fix
|
|
89
|
+
// instruction directly from the diff's measured values.
|
|
90
|
+
function synthesizeFixFromDiff(diff) {
|
|
91
|
+
const { area, live, build, deltaPx } = diff
|
|
92
|
+
if (live != null && build != null && deltaPx != null) {
|
|
93
|
+
if (typeof live === 'string' && typeof build === 'string') {
|
|
94
|
+
return `update \`${area}\` from build value \`${build}\` to live-page value \`${live}\` (delta ${deltaPx}px)`
|
|
95
|
+
}
|
|
96
|
+
return `update \`${area}\` from build value ${build}px to live-page value ${live}px (delta ${deltaPx}px)`
|
|
97
|
+
}
|
|
98
|
+
if (live != null && build != null) {
|
|
99
|
+
return `update \`${area}\` from build value \`${build}\` to live-page value \`${live}\``
|
|
100
|
+
}
|
|
101
|
+
return `align \`${area}\` with the live-page value — see compareDiffs entry for measurements`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function sortByPriority(violations) {
|
|
105
|
+
// High severity first; within severity, correlated-with-diff items rank
|
|
106
|
+
// ahead of non-correlated. Stable beyond that.
|
|
107
|
+
return [...violations].sort((a, b) => {
|
|
108
|
+
const sa = SEVERITY_ORDER[a.severity] ?? 99
|
|
109
|
+
const sb = SEVERITY_ORDER[b.severity] ?? 99
|
|
110
|
+
if (sa !== sb) return sa - sb
|
|
111
|
+
const ca = a.correlatedDiff ? 0 : 1
|
|
112
|
+
const cb = b.correlatedDiff ? 0 : 1
|
|
113
|
+
return ca - cb
|
|
114
|
+
})
|
|
115
|
+
}
|