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,327 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// build-wp.mjs — Plumbing for sb-build-wp.
|
|
3
|
+
//
|
|
4
|
+
// The LLM composes the HTML; this script handles the deterministic plumbing
|
|
5
|
+
// around that composition: structural validation of the produced HTML, and
|
|
6
|
+
// formatting + persistence to the output path.
|
|
7
|
+
//
|
|
8
|
+
// Two subcommands:
|
|
9
|
+
// validate --html-file <path> Check structural rules, JSON to stdout.
|
|
10
|
+
// Exit 0 if all errors clean, 1 if errors.
|
|
11
|
+
// write --output-path <path> Read HTML from stdin, optional prettier
|
|
12
|
+
// format (graceful skip if not installed),
|
|
13
|
+
// write to disk, JSON to stdout.
|
|
14
|
+
//
|
|
15
|
+
// Validation rules check the *mechanical* subset of the rules in
|
|
16
|
+
// references/wp-build-rules.md — the parts that can be checked with regex.
|
|
17
|
+
// Semantic correctness (correct pattern picked, defensive specificity actually
|
|
18
|
+
// applied where it matters, alt text being meaningful) is the LLM's job.
|
|
19
|
+
//
|
|
20
|
+
// prettier is imported lazily and is OPTIONAL. If it's not installed, write
|
|
21
|
+
// still succeeds — the HTML is just persisted unformatted.
|
|
22
|
+
//
|
|
23
|
+
// Exit codes: 0=ok, 1=script error, 2=invalid args, 3=validation failed.
|
|
24
|
+
|
|
25
|
+
import { parseArgs } from 'node:util'
|
|
26
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises'
|
|
27
|
+
import { dirname, resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
const HELP = `
|
|
30
|
+
build-wp.mjs — Validate and persist HTML produced by sb-build-wp.
|
|
31
|
+
|
|
32
|
+
Subcommands:
|
|
33
|
+
validate --html-file <path> Run structural lint on the HTML file.
|
|
34
|
+
write --output-path <path> Read HTML from stdin, format if prettier
|
|
35
|
+
is available, write to <output-path>.
|
|
36
|
+
|
|
37
|
+
Common flags:
|
|
38
|
+
--help Show this message.
|
|
39
|
+
--verbose Extra diagnostics to stderr.
|
|
40
|
+
|
|
41
|
+
Exit codes: 0=ok, 1=script error, 2=invalid args, 3=validation failed.
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
function fail(msg, code = 2) {
|
|
45
|
+
process.stderr.write(`[sb-build-wp] ${msg}\n`)
|
|
46
|
+
process.exit(code)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function log(verbose, msg) {
|
|
50
|
+
if (verbose) process.stderr.write(`[sb-build-wp] ${msg}\n`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readStdin() {
|
|
54
|
+
const chunks = []
|
|
55
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
56
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Validation rules ---------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function findAll(re, str) {
|
|
62
|
+
const out = []
|
|
63
|
+
const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`
|
|
64
|
+
const r = new RegExp(re.source, flags)
|
|
65
|
+
for (let m = r.exec(str); m !== null; m = r.exec(str)) {
|
|
66
|
+
out.push(m)
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateHtml(html) {
|
|
72
|
+
const errors = []
|
|
73
|
+
const warnings = []
|
|
74
|
+
const info = []
|
|
75
|
+
|
|
76
|
+
// 1. <style> block must exist.
|
|
77
|
+
if (!/<style\b[^>]*>[\s\S]*?<\/style>/i.test(html)) {
|
|
78
|
+
errors.push({
|
|
79
|
+
rule: 'missing-style-block',
|
|
80
|
+
message: 'No <style> block found. Output must be a self-contained fragment with scoped CSS.',
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Every <img> must have alt attribute (decorative images use alt="").
|
|
85
|
+
const imgs = findAll(/<img\b[^>]*>/gi, html)
|
|
86
|
+
imgs.forEach((m, i) => {
|
|
87
|
+
if (!/\balt\s*=/i.test(m[0])) {
|
|
88
|
+
errors.push({
|
|
89
|
+
rule: 'img-missing-alt',
|
|
90
|
+
message: `<img> #${i + 1} is missing alt attribute. Decorative images must use alt="".`,
|
|
91
|
+
snippet: m[0].slice(0, 120),
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// 3. Every <button> must have type= (anything other than missing — submit/button/reset all explicit is fine).
|
|
97
|
+
const buttons = findAll(/<button\b[^>]*>/gi, html)
|
|
98
|
+
buttons.forEach((m, i) => {
|
|
99
|
+
if (!/\btype\s*=/i.test(m[0])) {
|
|
100
|
+
errors.push({
|
|
101
|
+
rule: 'button-missing-type',
|
|
102
|
+
message: `<button> #${i + 1} is missing type attribute. Use type="button" unless intentionally a submit.`,
|
|
103
|
+
snippet: m[0].slice(0, 120),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// 4. Anti-pattern #8 — fabricated raster-as-SVG via inline data URI in <img src>.
|
|
109
|
+
imgs.forEach((m) => {
|
|
110
|
+
const srcMatch = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(m[0])
|
|
111
|
+
if (srcMatch && /^data:image\/svg\+xml/i.test(srcMatch[1])) {
|
|
112
|
+
errors.push({
|
|
113
|
+
rule: 'fabricated-svg-data-uri',
|
|
114
|
+
message: 'Anti-pattern #8: <img src="data:image/svg+xml,..."> indicates a fabricated SVG. Use the real asset from assetsMap.',
|
|
115
|
+
snippet: `${srcMatch[1].slice(0, 80)}...`,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// 5. Anti-pattern #1 — 100vh used on a height-related property in CSS.
|
|
121
|
+
// Match height/min-height: 100vh (also catches 100 vh just in case).
|
|
122
|
+
const styleBlocks = findAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, html)
|
|
123
|
+
const css = styleBlocks.map((m) => m[1]).join('\n')
|
|
124
|
+
const vhUses = findAll(/(min-height|height)\s*:\s*100vh\b/gi, css)
|
|
125
|
+
if (vhUses.length > 0) {
|
|
126
|
+
errors.push({
|
|
127
|
+
rule: 'hero-100vh',
|
|
128
|
+
message: `Anti-pattern #1: 100vh used on ${vhUses.length} height declaration(s). Use aspect-ratio + max-height instead.`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 6. Google Fonts stylesheet must include display=swap. Scope to stylesheet
|
|
133
|
+
// links pointing at fonts.googleapis.com/css — preconnect links to the same
|
|
134
|
+
// origin are not stylesheets and don't carry the swap parameter.
|
|
135
|
+
const allFontsLinks = findAll(/<link\b[^>]*>/gi, html).filter((m) =>
|
|
136
|
+
/\bhref\s*=\s*["'][^"']*fonts\.googleapis\.com\/css/i.test(m[0])
|
|
137
|
+
)
|
|
138
|
+
const fontsStylesheets = allFontsLinks.filter((m) =>
|
|
139
|
+
/\brel\s*=\s*["']stylesheet["']/i.test(m[0])
|
|
140
|
+
)
|
|
141
|
+
fontsStylesheets.forEach((m, i) => {
|
|
142
|
+
const href = /\bhref\s*=\s*["']([^"']+)["']/i.exec(m[0])
|
|
143
|
+
if (href && !/display=swap/i.test(href[1])) {
|
|
144
|
+
errors.push({
|
|
145
|
+
rule: 'fonts-missing-display-swap',
|
|
146
|
+
message: `Google Fonts stylesheet #${i + 1} is missing display=swap. Add &display=swap to the URL.`,
|
|
147
|
+
snippet: href[1].slice(0, 120),
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// 7. If Google Fonts stylesheet present, preconnect for fonts.gstatic.com should also be present.
|
|
153
|
+
if (fontsStylesheets.length > 0) {
|
|
154
|
+
const hasGstaticPreconnect = /<link\b[^>]*rel\s*=\s*["']preconnect["'][^>]*fonts\.gstatic\.com/i.test(html)
|
|
155
|
+
if (!hasGstaticPreconnect) {
|
|
156
|
+
warnings.push({
|
|
157
|
+
rule: 'fonts-missing-gstatic-preconnect',
|
|
158
|
+
message: 'Google Fonts stylesheet present but no <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>.',
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 8. If multiple images, most should have loading attribute. Warn when most are missing.
|
|
164
|
+
if (imgs.length >= 2) {
|
|
165
|
+
const withoutLoading = imgs.filter((m) => !/\bloading\s*=/i.test(m[0])).length
|
|
166
|
+
if (withoutLoading >= imgs.length - 1) {
|
|
167
|
+
// -1 leaves room for the LCP image which should be loading="eager" or have no attr
|
|
168
|
+
warnings.push({
|
|
169
|
+
rule: 'imgs-missing-loading',
|
|
170
|
+
message: `${withoutLoading}/${imgs.length} <img> tags lack a loading attribute. The hero/LCP can be eager; the rest should be lazy.`,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 9. If any image is in the markup, the hero should have a preload link (heuristic).
|
|
176
|
+
if (imgs.length > 0) {
|
|
177
|
+
const hasPreload = /<link\b[^>]*rel\s*=\s*["']preload["'][^>]*as\s*=\s*["']image["']/i.test(html) ||
|
|
178
|
+
/<link\b[^>]*as\s*=\s*["']image["'][^>]*rel\s*=\s*["']preload["']/i.test(html)
|
|
179
|
+
if (!hasPreload) {
|
|
180
|
+
warnings.push({
|
|
181
|
+
rule: 'no-hero-preload',
|
|
182
|
+
message: 'No <link rel="preload" as="image"> found. The hero image should be preloaded for LCP.',
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 10. Defensive specificity heuristic: count selectors with the scope class repeated as ancestor.
|
|
188
|
+
// We can't know the scope name from the HTML alone, but we can check for the pattern
|
|
189
|
+
// ".something .something__" which is a strong signal of correct chaining.
|
|
190
|
+
if (css.length > 0) {
|
|
191
|
+
const chainedSelectors = findAll(/\.([a-z][a-z0-9_-]*)\s+\.\1__/gi, css).length
|
|
192
|
+
info.push({
|
|
193
|
+
rule: 'defensive-specificity-count',
|
|
194
|
+
message: `Found ${chainedSelectors} selector(s) using the chained-scope pattern (.scope .scope__X). More is better.`,
|
|
195
|
+
})
|
|
196
|
+
if (chainedSelectors === 0 && css.length > 200) {
|
|
197
|
+
warnings.push({
|
|
198
|
+
rule: 'no-defensive-specificity',
|
|
199
|
+
message: 'No selectors found using the chained-scope pattern (.scope .scope__X). Anti-pattern #5b: theme overrides will win.',
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 11. Reset rule heuristic.
|
|
205
|
+
if (css.length > 0 && !/box-sizing\s*:\s*border-box/i.test(css)) {
|
|
206
|
+
warnings.push({
|
|
207
|
+
rule: 'no-reset-box-sizing',
|
|
208
|
+
message: 'No box-sizing: border-box reset found. Add the universal reset to the top of the <style> block.',
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
passed: errors.length === 0,
|
|
214
|
+
errorCount: errors.length,
|
|
215
|
+
warningCount: warnings.length,
|
|
216
|
+
errors,
|
|
217
|
+
warnings,
|
|
218
|
+
info,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- prettier (lazy, optional) ------------------------------------------------
|
|
223
|
+
|
|
224
|
+
async function tryPrettierFormat(html, verbose) {
|
|
225
|
+
let prettier
|
|
226
|
+
try {
|
|
227
|
+
prettier = await import('prettier')
|
|
228
|
+
} catch {
|
|
229
|
+
log(verbose, 'prettier not installed — writing unformatted')
|
|
230
|
+
return { html, formatted: false, reason: 'prettier-not-installed' }
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const out = await prettier.format(html, {
|
|
234
|
+
parser: 'html',
|
|
235
|
+
printWidth: 100,
|
|
236
|
+
tabWidth: 2,
|
|
237
|
+
htmlWhitespaceSensitivity: 'css',
|
|
238
|
+
})
|
|
239
|
+
return { html: out, formatted: true, reason: null }
|
|
240
|
+
} catch (err) {
|
|
241
|
+
log(verbose, `prettier failed: ${err.message} — writing unformatted`)
|
|
242
|
+
return { html, formatted: false, reason: `prettier-error: ${err.message}` }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- entry --------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
const argv = process.argv.slice(2)
|
|
249
|
+
|
|
250
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
251
|
+
process.stdout.write(HELP)
|
|
252
|
+
process.exit(0)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const subcommand = argv[0]
|
|
256
|
+
const rest = argv.slice(1)
|
|
257
|
+
|
|
258
|
+
if (subcommand !== 'validate' && subcommand !== 'write') {
|
|
259
|
+
fail(`unknown subcommand '${subcommand}' — use 'validate' or 'write'`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { values } = parseArgs({
|
|
263
|
+
args: rest,
|
|
264
|
+
options: {
|
|
265
|
+
'html-file': { type: 'string' },
|
|
266
|
+
'output-path': { type: 'string' },
|
|
267
|
+
verbose: { type: 'boolean', default: false },
|
|
268
|
+
help: { type: 'boolean', default: false },
|
|
269
|
+
},
|
|
270
|
+
strict: false,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
if (values.help) {
|
|
274
|
+
process.stdout.write(HELP)
|
|
275
|
+
process.exit(0)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function main() {
|
|
279
|
+
if (subcommand === 'validate') {
|
|
280
|
+
if (!values['html-file']) fail('validate: missing --html-file')
|
|
281
|
+
const path = resolve(values['html-file'])
|
|
282
|
+
let html
|
|
283
|
+
try {
|
|
284
|
+
html = await readFile(path, 'utf8')
|
|
285
|
+
} catch (err) {
|
|
286
|
+
fail(`validate: cannot read ${path}: ${err.message}`, 1)
|
|
287
|
+
}
|
|
288
|
+
log(values.verbose, `validating ${path} (${html.length} bytes)`)
|
|
289
|
+
const report = validateHtml(html)
|
|
290
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
291
|
+
process.exit(report.passed ? 0 : 3)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (subcommand === 'write') {
|
|
295
|
+
if (!values['output-path']) fail('write: missing --output-path')
|
|
296
|
+
const out = resolve(values['output-path'])
|
|
297
|
+
let html
|
|
298
|
+
try {
|
|
299
|
+
html = await readStdin()
|
|
300
|
+
} catch (err) {
|
|
301
|
+
fail(`write: failed to read stdin: ${err.message}`, 1)
|
|
302
|
+
}
|
|
303
|
+
if (!html.trim()) fail('write: stdin was empty', 2)
|
|
304
|
+
log(values.verbose, `read ${html.length} bytes from stdin`)
|
|
305
|
+
const fmt = await tryPrettierFormat(html, values.verbose)
|
|
306
|
+
try {
|
|
307
|
+
await mkdir(dirname(out), { recursive: true })
|
|
308
|
+
await writeFile(out, fmt.html, 'utf8')
|
|
309
|
+
} catch (err) {
|
|
310
|
+
fail(`write: failed to write ${out}: ${err.message}`, 1)
|
|
311
|
+
}
|
|
312
|
+
const payload = JSON.stringify(
|
|
313
|
+
{
|
|
314
|
+
path: out,
|
|
315
|
+
bytes: Buffer.byteLength(fmt.html, 'utf8'),
|
|
316
|
+
formatted: fmt.formatted,
|
|
317
|
+
formatterSkippedReason: fmt.reason,
|
|
318
|
+
},
|
|
319
|
+
null,
|
|
320
|
+
2,
|
|
321
|
+
)
|
|
322
|
+
process.stdout.write(`${payload}\n`)
|
|
323
|
+
process.exit(0)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
main().catch((err) => fail(`unhandled: ${err.stack || err.message}`, 1))
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-build-wp.mjs — Smoke tests for build-wp.mjs.
|
|
3
|
+
// Validates: --help works, missing args fail with exit 2, validate catches the
|
|
4
|
+
// canonical anti-patterns (exit 3), validate passes a clean fragment (exit 0),
|
|
5
|
+
// write persists to the requested path. No prettier dependency required for
|
|
6
|
+
// tests to pass.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { dirname, resolve, join } from 'node:path'
|
|
11
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { strict as assert } from 'node:assert'
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const SCRIPT = resolve(here, '..', 'build-wp.mjs')
|
|
17
|
+
const TMP = join(tmpdir(), `sb-build-wp-test-${process.pid}`)
|
|
18
|
+
|
|
19
|
+
mkdirSync(TMP, { recursive: true })
|
|
20
|
+
|
|
21
|
+
let passed = 0
|
|
22
|
+
let failed = 0
|
|
23
|
+
|
|
24
|
+
function test(name, fn) {
|
|
25
|
+
try {
|
|
26
|
+
fn()
|
|
27
|
+
process.stdout.write(`ok - ${name}\n`)
|
|
28
|
+
passed++
|
|
29
|
+
} catch (err) {
|
|
30
|
+
process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
|
|
31
|
+
failed++
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(args, input) {
|
|
36
|
+
return spawnSync('node', [SCRIPT, ...args], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
input: input ?? undefined,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeTmp(name, content) {
|
|
43
|
+
const p = join(TMP, name)
|
|
44
|
+
writeFileSync(p, content)
|
|
45
|
+
return p
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CLEAN_HTML = `
|
|
49
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
50
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
51
|
+
<link rel="preload" as="image" href="/assets/abc123.jpg">
|
|
52
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">
|
|
53
|
+
<style>
|
|
54
|
+
.hero, .hero * { box-sizing: border-box; margin: 0; padding: 0; }
|
|
55
|
+
.hero { width: 100vw; margin-left: calc(50% - 50vw); }
|
|
56
|
+
.hero .hero__title { font-size: 32px !important; font-weight: 700 !important; }
|
|
57
|
+
.hero .hero__media { aspect-ratio: 1 / 1.3; max-height: 700px; }
|
|
58
|
+
@media (min-width: 1000px) {
|
|
59
|
+
.hero .hero__media { aspect-ratio: 16 / 9; }
|
|
60
|
+
}
|
|
61
|
+
</style>
|
|
62
|
+
<section class="hero">
|
|
63
|
+
<img src="/assets/abc123.jpg" alt="Brand hero" loading="eager">
|
|
64
|
+
<h1 class="hero__title">Welcome</h1>
|
|
65
|
+
<button type="button" class="hero__cta" aria-label="Shop now">Shop</button>
|
|
66
|
+
<img src="/assets/def456.jpg" alt="" loading="lazy">
|
|
67
|
+
</section>
|
|
68
|
+
`
|
|
69
|
+
|
|
70
|
+
// ---- Help and arg validation ----
|
|
71
|
+
|
|
72
|
+
test('--help exits 0 and prints usage', () => {
|
|
73
|
+
const r = run(['--help'])
|
|
74
|
+
assert.equal(r.status, 0, `exit code was ${r.status}`)
|
|
75
|
+
assert.match(r.stdout, /build-wp\.mjs/)
|
|
76
|
+
assert.match(r.stdout, /validate/)
|
|
77
|
+
assert.match(r.stdout, /write/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('no args exits 0 and shows help', () => {
|
|
81
|
+
const r = run([])
|
|
82
|
+
assert.equal(r.status, 0)
|
|
83
|
+
assert.match(r.stdout, /Subcommands/)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('unknown subcommand exits 2', () => {
|
|
87
|
+
const r = run(['frobnicate'])
|
|
88
|
+
assert.equal(r.status, 2)
|
|
89
|
+
assert.match(r.stderr, /unknown subcommand/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('validate without --html-file exits 2', () => {
|
|
93
|
+
const r = run(['validate'])
|
|
94
|
+
assert.equal(r.status, 2)
|
|
95
|
+
assert.match(r.stderr, /missing --html-file/)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('write without --output-path exits 2', () => {
|
|
99
|
+
const r = run(['write'], '<style></style>')
|
|
100
|
+
assert.equal(r.status, 2)
|
|
101
|
+
assert.match(r.stderr, /missing --output-path/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('write with empty stdin exits 2', () => {
|
|
105
|
+
const r = run(['write', '--output-path', join(TMP, 'never.html')], '')
|
|
106
|
+
assert.equal(r.status, 2)
|
|
107
|
+
assert.match(r.stderr, /stdin was empty/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ---- Validation: clean fragment passes ----
|
|
111
|
+
|
|
112
|
+
test('validate: clean fragment passes (exit 0)', () => {
|
|
113
|
+
const p = writeTmp('clean.html', CLEAN_HTML)
|
|
114
|
+
const r = run(['validate', '--html-file', p])
|
|
115
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`)
|
|
116
|
+
const report = JSON.parse(r.stdout)
|
|
117
|
+
assert.equal(report.passed, true)
|
|
118
|
+
assert.equal(report.errorCount, 0)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ---- Validation: each rule trips when violated ----
|
|
122
|
+
|
|
123
|
+
test('validate: missing <style> reports error', () => {
|
|
124
|
+
const p = writeTmp('no-style.html', '<section><h1>hi</h1></section>')
|
|
125
|
+
const r = run(['validate', '--html-file', p])
|
|
126
|
+
assert.equal(r.status, 3)
|
|
127
|
+
const report = JSON.parse(r.stdout)
|
|
128
|
+
assert.ok(report.errors.find((e) => e.rule === 'missing-style-block'))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('validate: <img> without alt reports error', () => {
|
|
132
|
+
const html = CLEAN_HTML.replace('<img src="/assets/abc123.jpg" alt="Brand hero" loading="eager">', '<img src="/assets/abc123.jpg" loading="eager">')
|
|
133
|
+
const p = writeTmp('no-alt.html', html)
|
|
134
|
+
const r = run(['validate', '--html-file', p])
|
|
135
|
+
assert.equal(r.status, 3)
|
|
136
|
+
const report = JSON.parse(r.stdout)
|
|
137
|
+
assert.ok(report.errors.find((e) => e.rule === 'img-missing-alt'))
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('validate: <button> without type reports error', () => {
|
|
141
|
+
const html = CLEAN_HTML.replace('<button type="button" class="hero__cta" aria-label="Shop now">Shop</button>', '<button class="hero__cta" aria-label="Shop now">Shop</button>')
|
|
142
|
+
const p = writeTmp('no-type.html', html)
|
|
143
|
+
const r = run(['validate', '--html-file', p])
|
|
144
|
+
assert.equal(r.status, 3)
|
|
145
|
+
const report = JSON.parse(r.stdout)
|
|
146
|
+
assert.ok(report.errors.find((e) => e.rule === 'button-missing-type'))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('validate: data:image/svg+xml in img src reports error', () => {
|
|
150
|
+
const html = CLEAN_HTML.replace(
|
|
151
|
+
'<img src="/assets/abc123.jpg" alt="Brand hero" loading="eager">',
|
|
152
|
+
'<img src="data:image/svg+xml,%3Csvg%3E%3C/svg%3E" alt="Brand hero" loading="eager">'
|
|
153
|
+
)
|
|
154
|
+
const p = writeTmp('fake-svg.html', html)
|
|
155
|
+
const r = run(['validate', '--html-file', p])
|
|
156
|
+
assert.equal(r.status, 3)
|
|
157
|
+
const report = JSON.parse(r.stdout)
|
|
158
|
+
assert.ok(report.errors.find((e) => e.rule === 'fabricated-svg-data-uri'))
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('validate: 100vh on height reports error', () => {
|
|
162
|
+
const html = CLEAN_HTML.replace('aspect-ratio: 1 / 1.3; max-height: 700px;', 'height: 100vh;')
|
|
163
|
+
const p = writeTmp('vh.html', html)
|
|
164
|
+
const r = run(['validate', '--html-file', p])
|
|
165
|
+
assert.equal(r.status, 3)
|
|
166
|
+
const report = JSON.parse(r.stdout)
|
|
167
|
+
assert.ok(report.errors.find((e) => e.rule === 'hero-100vh'))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('validate: Google Fonts without display=swap reports error', () => {
|
|
171
|
+
const html = CLEAN_HTML.replace('&display=swap', '')
|
|
172
|
+
const p = writeTmp('no-swap.html', html)
|
|
173
|
+
const r = run(['validate', '--html-file', p])
|
|
174
|
+
assert.equal(r.status, 3)
|
|
175
|
+
const report = JSON.parse(r.stdout)
|
|
176
|
+
assert.ok(report.errors.find((e) => e.rule === 'fonts-missing-display-swap'))
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('validate: missing reset emits warning (not error)', () => {
|
|
180
|
+
const html = `<style>.x .x__y { color: red; }</style><section class="x"><span class="x__y">hi</span></section>`
|
|
181
|
+
const p = writeTmp('no-reset.html', html)
|
|
182
|
+
const r = run(['validate', '--html-file', p])
|
|
183
|
+
// No errors → exit 0 even with warnings.
|
|
184
|
+
assert.equal(r.status, 0)
|
|
185
|
+
const report = JSON.parse(r.stdout)
|
|
186
|
+
assert.ok(report.warnings.find((w) => w.rule === 'no-reset-box-sizing'))
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ---- write subcommand ----
|
|
190
|
+
|
|
191
|
+
test('write: persists stdin to output path', () => {
|
|
192
|
+
const out = join(TMP, 'nested', 'out.html')
|
|
193
|
+
const html = '<style>.x{color:red}</style><div class="x">hi</div>'
|
|
194
|
+
const r = run(['write', '--output-path', out], html)
|
|
195
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`)
|
|
196
|
+
const report = JSON.parse(r.stdout)
|
|
197
|
+
assert.equal(report.path, out)
|
|
198
|
+
assert.ok(report.bytes > 0)
|
|
199
|
+
assert.ok(existsSync(out))
|
|
200
|
+
const content = readFileSync(out, 'utf8')
|
|
201
|
+
// prettier may or may not be installed — the content must at least include the input
|
|
202
|
+
assert.match(content, /<style>/)
|
|
203
|
+
assert.match(content, /\.x/)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('write: creates parent directories', () => {
|
|
207
|
+
const out = join(TMP, 'a', 'b', 'c', 'deep.html')
|
|
208
|
+
const r = run(['write', '--output-path', out], '<p>x</p>')
|
|
209
|
+
assert.equal(r.status, 0)
|
|
210
|
+
assert.ok(existsSync(out))
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ---- Cleanup ----
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
rmSync(TMP, { recursive: true, force: true })
|
|
217
|
+
} catch {}
|
|
218
|
+
|
|
219
|
+
if (failed > 0) {
|
|
220
|
+
process.stdout.write(`\n${failed} failed, ${passed} passed\n`)
|
|
221
|
+
process.exit(1)
|
|
222
|
+
}
|
|
223
|
+
process.stdout.write(`\n${passed} passed\n`)
|
|
224
|
+
process.exit(0)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sb-compare-visual
|
|
3
|
+
description: Compares the live-page screenshot against the build screenshot pixel-by-pixel and (optionally) cross-checks measured tokens, returning a prioritized list of structured diffs and a red-overlay diff map. Use when the SimilarBuild orchestrator (`/build-page`, `/build-site`, `/clip-section`) requests visual comparison after `sb-validate-render`, or when the user asks to 'compare visual' between a live page and a build.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# sb-compare-visual
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Closes the **validate → compare** loop. Takes the screenshot `sb-validate-render` produced (the build, rendered with the destination's theme reset injected) and the live-page screenshot `sb-inspect-live` captured, normalizes their canvas with sharp, runs pixelmatch over the pair, and writes a red-overlay diff map. When the optional `--tokens-live` and `--tokens-build` JSON files are passed, it additionally cross-checks the measured tokens (h1/h2/h3 size + color, body typography, button geometry, container width, viewport overflow) and emits a prioritized list of structured diffs that the orchestrator feeds to `sb-review-checks` for fix candidates.
|
|
11
|
+
|
|
12
|
+
Pure determinism — no browser, no network. The script does the work; this SKILL.md only describes inputs, outputs, and how to react to a non-zero exit.
|
|
13
|
+
|
|
14
|
+
This skill **only reports differences**. Generating fix suggestions is `sb-review-checks`'s job. Keep that separation: a clean diff list now means a clean fix list later.
|
|
15
|
+
|
|
16
|
+
## Inputs
|
|
17
|
+
|
|
18
|
+
| Argument | Required | Default | Notes |
|
|
19
|
+
| -------------------- | -------- | ------- | --------------------------------------------------------------------------- |
|
|
20
|
+
| `live-screenshot` | yes | — | PNG from `sb-inspect-live`. |
|
|
21
|
+
| `build-screenshot` | yes | — | PNG from `sb-validate-render` (`{render-output-dir}/screenshot.png`). |
|
|
22
|
+
| `output-dir` | yes | — | Directory for `diff-map.png` + `report.json`. |
|
|
23
|
+
| `tokens-live` | no | — | JSON from `sb-inspect-live` (provides `tokens`/`geometry`). |
|
|
24
|
+
| `tokens-build` | no | — | `render.json` from `sb-validate-render`. |
|
|
25
|
+
| `threshold` | no | `10` | Pass threshold for `diffPercent`. Comes from config `diff_threshold_percent`. Raised from 5 (Pattern #22 + smoke): pixelmatch is severe on font/anti-aliasing drift even when visual fidelity is good. |
|
|
26
|
+
| `pixel-threshold` | no | `0.2` | pixelmatch per-pixel sensitivity (0=strict, 1=permissive). Raised from 0.1 (Pattern #22 + smoke): 0.1 flagged identical-color screenshots with sub-pixel font shifts as 80%+ different. |
|
|
27
|
+
| `crop-live-bbox` | no | — | `x,y,w,h` in CSS pixels. When the build is a single section but the live screenshot is full-page, pass `inspection.sectionBoundingBox` here so the live is cropped before diffing (anti-pattern #10). |
|
|
28
|
+
| `crop-live-dpr` | no | `3` | DPR multiplier for `--crop-live-bbox`. `sb-inspect-live` captures with the iPhone 14 device profile (`deviceScaleFactor=3`), so screenshot pixels = CSS pixels × 3. Override only if the inspector ran with a different profile. |
|
|
29
|
+
| `crop-build-bbox` | no | — | `x,y,w,h` in CSS pixels. Symmetric of `--crop-live-bbox` (Pattern #27): when the build is a section shorter than the captured viewport, sb-validate-render's screenshot has dead viewport space below the content. Pass `render.geometry.sections[0].bbox` (or `probeRoot.bbox`) here to crop before diffing. Skip when `render.geometry.viewportOverflow === true` (bbox would underestimate). |
|
|
30
|
+
| `crop-build-dpr` | no | `3` | DPR multiplier for `--crop-build-bbox`. `sb-validate-render` runs with `deviceScaleFactor=3` (Pattern #22 alignment), so screenshot pixels = CSS pixels × 3. Override only if validate-render ran at a different DPR. |
|
|
31
|
+
|
|
32
|
+
Pass both `--tokens-live` and `--tokens-build` together — passing one without the other silently skips the token diff (the comparison only makes sense with both sides).
|
|
33
|
+
|
|
34
|
+
## Output
|
|
35
|
+
|
|
36
|
+
A single JSON object printed to stdout AND saved to `{output-dir}/report.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"passed": false,
|
|
41
|
+
"diffPercent": 6.42,
|
|
42
|
+
"diffPixels": 50124,
|
|
43
|
+
"totalPixels": 780480,
|
|
44
|
+
"threshold": 5,
|
|
45
|
+
"pixelThreshold": 0.1,
|
|
46
|
+
"diffMap": "{output-dir}/diff-map.png",
|
|
47
|
+
"report": "{output-dir}/report.json",
|
|
48
|
+
"dimensions": {
|
|
49
|
+
"live": { "width": 780, "height": 2014 },
|
|
50
|
+
"build": { "width": 780, "height": 1972 },
|
|
51
|
+
"canvas": { "width": 780, "height": 2014 }
|
|
52
|
+
},
|
|
53
|
+
"tokenDiff": { "compared": true, "count": 3 },
|
|
54
|
+
"structuredDiffs": [
|
|
55
|
+
{ "area": "h1", "issue": "font-size 24px vs 27px expected", "severity": "high", "live": "27px", "build": "24px", "deltaPx": -3 },
|
|
56
|
+
{ "area": "button", "issue": "background-color #d8112a vs #d82a11 expected", "severity": "high", "live": "#d82a11", "build": "#d8112a" },
|
|
57
|
+
{ "area": "button", "issue": "button height 44px vs 40px expected", "severity": "medium", "live": 40, "build": 44, "deltaPx": 4 }
|
|
58
|
+
],
|
|
59
|
+
"croppedLiveTo": null,
|
|
60
|
+
"croppedBuildTo": null,
|
|
61
|
+
"inputs": { "liveScreenshot": "...", "buildScreenshot": "...", "tokensLive": "...", "tokensBuild": "..." }
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
When `--crop-live-bbox` was passed, `croppedLiveTo` carries the bbox echo + screenshot-pixel resolution + the path of the cropped live PNG (also written to `{output-dir}/live-cropped.png` for debug). When omitted, it's `null`. `croppedBuildTo` mirrors this for `--crop-build-bbox` (debug PNG: `{output-dir}/build-cropped.png`).
|
|
66
|
+
|
|
67
|
+
`structuredDiffs` is sorted **high → medium → low** so the orchestrator's first slice is always the most urgent. `diff-map.png` is written even when `passed: true` — useful for spot-checking close calls.
|
|
68
|
+
|
|
69
|
+
## Dependencies
|
|
70
|
+
|
|
71
|
+
The host project must have `sharp`, `pixelmatch`, and `pngjs` installed (the SimilarBuild installer handles all three; sharp is already a dep of `sb-extract-assets`, pixelmatch + pngjs are new). Node ≥ 20.
|
|
72
|
+
|
|
73
|
+
## On Activation
|
|
74
|
+
|
|
75
|
+
1. **Resolve inputs.** Collect `live-screenshot`, `build-screenshot`, `output-dir`. Optional: `tokens-live`, `tokens-build`, `threshold`, `pixel-threshold`. Pass through `diff_threshold_percent` from `.claude/sb-config.yaml` if available; otherwise the script's default of 5% applies.
|
|
76
|
+
|
|
77
|
+
2. **Ensure `output-dir` exists.** `mkdir -p` it.
|
|
78
|
+
|
|
79
|
+
3. **Run the script.** From the project root:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
node {skill-root}/scripts/compare-visual.mjs \
|
|
83
|
+
--live-screenshot "{live-png}" \
|
|
84
|
+
--build-screenshot "{build-png}" \
|
|
85
|
+
--output-dir "{output-dir}" \
|
|
86
|
+
[--tokens-live "{inspection.json}"] \
|
|
87
|
+
[--tokens-build "{render.json}"] \
|
|
88
|
+
[--threshold 10] \
|
|
89
|
+
[--pixel-threshold 0.2] \
|
|
90
|
+
[--crop-live-bbox "{x,y,w,h}"] \
|
|
91
|
+
[--crop-live-dpr 3] \
|
|
92
|
+
[--crop-build-bbox "{x,y,w,h}"] \
|
|
93
|
+
[--crop-build-dpr 3]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The script handles: dimension probe, transparent-pad to the union canvas (NOT resize — resize distorts and triggers false positives), PNG decode via pngjs, pixelmatch, red-overlay diff PNG, and the token cross-check. See `scripts/compare-visual.mjs --help` for the full flag list.
|
|
97
|
+
|
|
98
|
+
4. **Branch on exit code.**
|
|
99
|
+
- `0` — `passed: true`, ship it.
|
|
100
|
+
- `3` — `passed: false`, comparison failed. **Not a script error.** Parse the JSON anyway; `structuredDiffs` is the input to `sb-review-checks` and the next re-roll of `sb-build-{wp,shopify}` (via its `fixHints`).
|
|
101
|
+
- `1` — actual script error. Surface stderr to the user and stop.
|
|
102
|
+
- `2` — invalid args. Fix the call.
|
|
103
|
+
|
|
104
|
+
5. **Forward the JSON unchanged** to the caller. Do not summarize, mutate, or strip fields. Downstream consumers (the orchestrator's auto-correct loop, `sb-review-checks`, the HTML report) read every field directly.
|
|
105
|
+
|
|
106
|
+
## Failure modes
|
|
107
|
+
|
|
108
|
+
| Symptom | Likely cause | What to surface |
|
|
109
|
+
| -------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
110
|
+
| Exit 1, stderr mentions `sharp`/`pixelmatch`/`pngjs` | Dependency not installed | Pass stderr verbatim. Suggest `npm i sharp pixelmatch pngjs`. |
|
|
111
|
+
| Exit 1, "could not read dimensions" | Input PNG is corrupt / 0 bytes (often: `sb-validate-render`'s `warnings[]` flagged a tiny screenshot upstream) | Pass stderr verbatim. The build screenshot is unusable — re-roll `sb-validate-render` first. |
|
|
112
|
+
| Exit 3 with `diffPercent > 50` | Massive layout drift OR build screenshot shows the wrong section | Look at `diff-map.png` first. If the entire image is red, the build is structurally wrong, not visually drifted — escalate to `sb-build-{wp,shopify}` re-roll, not `sb-review-checks`. |
|
|
113
|
+
| `tokenDiff.compared: false`, `reason: tokens-unreadable` | One of the JSON files is malformed | Inspect the source skill's output. Pixel diff is still valid; token diff is just absent. |
|
|
114
|
+
| `structuredDiffs[].severity: 'high'` for all entries | Wholly different theme — fonts, colors, geometry all drifted | Likely missing or wrong preset YAML in `sb-validate-render` — its theme reset isn't matching the destination. |
|
|
115
|
+
|
|
116
|
+
## Conventions
|
|
117
|
+
|
|
118
|
+
- Bare paths (e.g. `scripts/compare-visual.mjs`) resolve from the skill root.
|
|
119
|
+
- `{skill-root}` resolves to this skill's installed directory.
|
|
120
|
+
- `{project-root}` resolves to the project working directory.
|
|
121
|
+
- Sizes are normalized by **transparent padding to the union canvas**, never by resizing. Resizing distorts pixel positions and produces a sea of red pixels even on near-identical inputs; padding only flags areas where one side has content the other lacks (which is exactly what we want to know).
|