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.
Files changed (76) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +301 -0
  4. package/bin/install.js +256 -0
  5. package/lib/copy-templates.mjs +52 -0
  6. package/lib/install-deps.mjs +62 -0
  7. package/lib/prompt-config.mjs +83 -0
  8. package/lib/verify-env.mjs +19 -0
  9. package/package.json +63 -0
  10. package/scripts/sync-templates.mjs +71 -0
  11. package/templates/commands/build-page.md +490 -0
  12. package/templates/commands/build-site.md +548 -0
  13. package/templates/commands/clip-section.md +519 -0
  14. package/templates/memory/anti-patterns.md +212 -0
  15. package/templates/memory/design-knowledge.md +225 -0
  16. package/templates/memory/fixes.md +163 -0
  17. package/templates/memory/patterns.md +681 -0
  18. package/templates/presets/shopify-section.yaml +51 -0
  19. package/templates/presets/wp-elementor.yaml +49 -0
  20. package/templates/reports/fixtures/mock-run-1.json +115 -0
  21. package/templates/reports/fixtures/mock-run-2.json +72 -0
  22. package/templates/reports/report-renderer.mjs +218 -0
  23. package/templates/reports/report-template.html +571 -0
  24. package/templates/skills/sb-build-shopify/SKILL.md +104 -0
  25. package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
  26. package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
  27. package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
  28. package/templates/skills/sb-build-wp/SKILL.md +83 -0
  29. package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
  30. package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
  31. package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
  32. package/templates/skills/sb-compare-visual/SKILL.md +121 -0
  33. package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
  34. package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
  35. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
  36. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
  37. package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
  38. package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
  39. package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
  40. package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
  41. package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
  42. package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
  43. package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
  44. package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
  45. package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
  46. package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
  47. package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
  48. package/templates/skills/sb-extract-assets/SKILL.md +112 -0
  49. package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
  50. package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
  51. package/templates/skills/sb-inspect-live/SKILL.md +105 -0
  52. package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
  53. package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
  54. package/templates/skills/sb-review-checks/SKILL.md +113 -0
  55. package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
  56. package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
  57. package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
  58. package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
  59. package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
  60. package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
  61. package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
  62. package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
  63. package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
  64. package/templates/skills/sb-tweak/SKILL.md +130 -0
  65. package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
  66. package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
  67. package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
  68. package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
  69. package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
  70. package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
  71. package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
  72. package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
  73. package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
  74. package/templates/skills/sb-validate-render/SKILL.md +120 -0
  75. package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
  76. package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
@@ -0,0 +1,541 @@
1
+ // design-quality.mjs — 12 pure-function checks for accessibility, performance,
2
+ // and web-standards baselines. These are not opinions — they are baselines
3
+ // every production fragment must clear. Inputs are the same `ctx` shape as
4
+ // anti-patterns.mjs: { html, css, $, preset, styleBlocks }.
5
+ //
6
+ // Mechanical vs semantic split (pattern #11): every check here detects a
7
+ // *structural* problem that regex/AST can find unambiguously. Things that
8
+ // require semantic judgment ("is this alt text meaningful?", "is this the
9
+ // right ARIA pattern?") are explicitly OUT — those are the LLM's job once
10
+ // the violations land in the orchestrator. The script flags presence/absence,
11
+ // not quality.
12
+
13
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 }
14
+ export function sortBySeverity(violations) {
15
+ return [...violations].sort((a, b) => {
16
+ const sa = SEVERITY_ORDER[a.severity] ?? 99
17
+ const sb = SEVERITY_ORDER[b.severity] ?? 99
18
+ return sa - sb
19
+ })
20
+ }
21
+
22
+ // Helpers ────────────────────────────────────────────────────────────────────
23
+
24
+ // Best-effort: a button is "icon-only" when its text content is empty (after
25
+ // trimming and stripping a single character / pictograph) and it has at most
26
+ // one child element (an svg or img). This errs on the side of false positives
27
+ // for the LLM to filter — better to flag a labeled button than miss a bare one.
28
+ function isIconOnlyButton($, $btn) {
29
+ const text = $btn.text().replace(/\s+/g, '').trim()
30
+ if (text.length > 1) return false
31
+ // If the only "text" is a single emoji/pictograph (e.g. ☰, ×) treat as icon
32
+ const children = $btn.children()
33
+ const hasGlyph = children.length === 0 && text.length === 1
34
+ if (hasGlyph) return true
35
+ if (children.length === 1) {
36
+ const tag = (children[0].tagName || '').toLowerCase()
37
+ if (tag === 'svg' || tag === 'img' || tag === 'i' || tag === 'span') return true
38
+ }
39
+ if (children.length === 0 && text.length === 0) return true
40
+ return false
41
+ }
42
+
43
+ // Identifies "the hero image" for preload/lazy/fetchpriority checks.
44
+ // Heuristic, in order: explicit fetchpriority="high"; ancestor class containing
45
+ // "hero"; first <img> in document order. Returns the cheerio element or null.
46
+ function findHeroImg($) {
47
+ const explicit = $('img[fetchpriority="high"]').first()
48
+ if (explicit.length) return explicit
49
+ const inHero = $('img').filter((_, el) => {
50
+ const $el = $(el)
51
+ let cur = $el
52
+ for (let i = 0; i < 8 && cur.length; i++) {
53
+ const cls = String(cur.attr('class') || '').toLowerCase()
54
+ if (/(^|[\s_-])hero([\s_-]|$)/.test(cls)) return true
55
+ cur = cur.parent()
56
+ }
57
+ return false
58
+ }).first()
59
+ if (inHero.length) return inHero
60
+ const first = $('img').first()
61
+ return first.length ? first : null
62
+ }
63
+
64
+ // Pattern #25 of the plan. The plain `findHeroImg` returns the first <img> as a
65
+ // last-resort guess — but in builds where the hero is a CSS background-image,
66
+ // the first <img> is the LOGO, not the hero. Flagging it as the hero produces
67
+ // false positives (the logo doesn't need preload — the real hero does, and the
68
+ // real hero is not an <img>). This variant returns a tiered confidence so
69
+ // callers can choose to skip the lowest tier ("fallback") when a CSS-bg path
70
+ // has already been considered.
71
+ const HERO_IMG_CONFIDENCE = ['explicit', 'hero-ancestor', 'name-match', 'wide', 'fallback']
72
+ export function findHeroImgWithConfidence($) {
73
+ const explicit = $('img[fetchpriority="high"]').first()
74
+ if (explicit.length) return { el: explicit, confidence: 'explicit' }
75
+ let inHero = null
76
+ $('img').each((_, el) => {
77
+ if (inHero) return
78
+ const $el = $(el)
79
+ let cur = $el
80
+ for (let i = 0; i < 8 && cur.length; i++) {
81
+ const cls = String(cur.attr('class') || '').toLowerCase()
82
+ if (/(^|[\s_-])hero([\s_-]|$)/.test(cls)) {
83
+ inHero = $el
84
+ return
85
+ }
86
+ cur = cur.parent()
87
+ }
88
+ })
89
+ if (inHero) return { el: inHero, confidence: 'hero-ancestor' }
90
+ let nameMatch = null
91
+ $('img').each((_, el) => {
92
+ if (nameMatch) return
93
+ const $el = $(el)
94
+ const src = String($el.attr('src') || $el.attr('data-src') || '').toLowerCase()
95
+ const alt = String($el.attr('alt') || '').toLowerCase()
96
+ if (/\b(hero|banner|cover)\b/.test(src) || /\b(hero|banner|cover)\b/.test(alt)) {
97
+ nameMatch = $el
98
+ }
99
+ })
100
+ if (nameMatch) return { el: nameMatch, confidence: 'name-match' }
101
+ let wideImg = null
102
+ $('img').each((_, el) => {
103
+ if (wideImg) return
104
+ const w = getImgWidth($(el))
105
+ if (Number.isFinite(w) && w >= 800) wideImg = $(el)
106
+ })
107
+ if (wideImg) return { el: wideImg, confidence: 'wide' }
108
+ const first = $('img').first()
109
+ if (first.length) return { el: first, confidence: 'fallback' }
110
+ return null
111
+ }
112
+
113
+ // Find the first CSS rule whose selector is a single root-scope class (no `__`
114
+ // BEM-modifier, no descendant combinator) and whose body declares a
115
+ // `background-image: url(...)`. The build skills (sb-build-wp,
116
+ // sb-build-shopify) emit the section's root scope as a single class — so a
117
+ // `background-image` declaration on that class is canonical hero-CSS-bg
118
+ // territory. Returns `{ selector, url }` or null.
119
+ export function findHeroCssBackgroundImage(css) {
120
+ if (!css || typeof css !== 'string') return null
121
+ // Strip CSS comments to keep the regex below simple.
122
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '')
123
+ // Walk top-level rule blocks. This keeps things readable; nested at-rules
124
+ // like @media{} won't match the simple `selector { ... }` form because the
125
+ // outer rule has nested braces — handled by skipping any "selector" that
126
+ // contains a `{`.
127
+ const RULE_RE = /([^{}]+?)\{([^{}]*?)\}/g
128
+ let m
129
+ while ((m = RULE_RE.exec(stripped)) !== null) {
130
+ const selectorRaw = m[1].trim()
131
+ const body = m[2]
132
+ if (!selectorRaw) continue
133
+ // Skip at-rules and comma-joined selectors with nested @media.
134
+ if (selectorRaw.startsWith('@')) continue
135
+ // For comma-separated selectors, accept if the FIRST selector is a root
136
+ // scope class — same heuristic as the build skills emit.
137
+ const firstSelector = selectorRaw.split(',')[0].trim()
138
+ // Single-class root scope: `.foo` or `.foo:pseudo`. Reject:
139
+ // - descendant/child combinators (`.foo .bar`, `.foo > .bar`)
140
+ // - BEM modifier (`__`)
141
+ // - id selectors, type selectors, attribute selectors
142
+ if (!/^\.[A-Za-z][\w-]*(?::[A-Za-z-]+(?:\([^)]*\))?)?$/.test(firstSelector)) continue
143
+ if (firstSelector.includes('__')) continue
144
+ const bgMatch = body.match(/background(?:-image)?\s*:\s*[^;]*?url\(\s*['"]?([^'")]+?)['"]?\s*\)/i)
145
+ if (!bgMatch) continue
146
+ return { selector: firstSelector, url: bgMatch[1] }
147
+ }
148
+ return null
149
+ }
150
+
151
+ function getImgWidth($img) {
152
+ const explicit = parseInt(String($img.attr('width') || ''), 10)
153
+ if (Number.isFinite(explicit) && explicit > 0) return explicit
154
+ const style = String($img.attr('style') || '')
155
+ const m = style.match(/(?:^|;|\s)width\s*:\s*(\d+)\s*px/i)
156
+ if (m) return parseInt(m[1], 10)
157
+ return null
158
+ }
159
+
160
+ function collectPreloadHrefs($) {
161
+ return $('link[rel="preload"][as="image"]')
162
+ .map((_, el) => String($(el).attr('href') || ''))
163
+ .get()
164
+ }
165
+
166
+ // Checks ─────────────────────────────────────────────────────────────────────
167
+
168
+ // a11y-button-aria — icon-only buttons need an aria-label
169
+ export function checkButtonAria(ctx) {
170
+ const { $ } = ctx
171
+ const violations = []
172
+ $('button').each((_, el) => {
173
+ const $el = $(el)
174
+ if (!isIconOnlyButton($, $el)) return
175
+ if ($el.attr('aria-label')) return
176
+ if ($el.attr('aria-labelledby')) return
177
+ // <img alt="..."> child satisfies a11y too
178
+ const innerImgAlt = $el.find('img[alt]').first().attr('alt')
179
+ if (innerImgAlt && innerImgAlt.trim().length > 0) return
180
+ const cls = $el.attr('class') || ''
181
+ const hint = inferActionHint($el)
182
+ violations.push({
183
+ group: 'a11y',
184
+ id: 'a11y-button-aria',
185
+ location: cls ? `<button class="${cls}">` : '<button> (icon-only)',
186
+ issue: 'icon-only <button> without aria-label — screen readers announce nothing',
187
+ candidateFix: `add \`aria-label="${hint}"\` to the <button>`,
188
+ severity: 'high',
189
+ })
190
+ })
191
+ return violations
192
+ }
193
+
194
+ function inferActionHint($btn) {
195
+ const cls = String($btn.attr('class') || '').toLowerCase()
196
+ if (/(close|dismiss|×)/.test(cls)) return 'Close'
197
+ if (/(open|menu|hamburger|drawer)/.test(cls)) return 'Open menu'
198
+ if (/(prev|previous|back)/.test(cls)) return 'Previous'
199
+ if (/(next|forward)/.test(cls)) return 'Next'
200
+ if (/(search|find)/.test(cls)) return 'Search'
201
+ if (/(cart|bag)/.test(cls)) return 'Cart'
202
+ return 'Button action'
203
+ }
204
+
205
+ // a11y-img-alt — every <img> must have an alt attribute (empty allowed)
206
+ export function checkImgAlt(ctx) {
207
+ const { $ } = ctx
208
+ const violations = []
209
+ $('img').each((_, el) => {
210
+ const $el = $(el)
211
+ if ($el.attr('alt') !== undefined) return
212
+ const src = $el.attr('src') || $el.attr('data-src') || ''
213
+ violations.push({
214
+ group: 'a11y',
215
+ id: 'a11y-img-alt',
216
+ location: src ? `<img src="${truncate(src, 60)}">` : '<img>',
217
+ issue: '<img> missing `alt` attribute — assistive tech reads the filename or skips the image',
218
+ candidateFix: `add \`alt=""\` (decorative) or \`alt="<short description>"\` to the <img src="${truncate(src, 60)}">`,
219
+ severity: 'high',
220
+ })
221
+ })
222
+ return violations
223
+ }
224
+
225
+ function truncate(s, n) {
226
+ return s.length > n ? s.slice(0, n - 1) + '…' : s
227
+ }
228
+
229
+ // a11y-heading-hierarchy — H1 absent OR multiple OR skips a level
230
+ export function checkHeadingHierarchy(ctx) {
231
+ const { $ } = ctx
232
+ const violations = []
233
+ const levels = []
234
+ $('h1, h2, h3, h4, h5, h6').each((_, el) => {
235
+ const tag = (el.tagName || '').toLowerCase()
236
+ levels.push({ tag, n: parseInt(tag.slice(1), 10) })
237
+ })
238
+ if (levels.length === 0) return violations
239
+ const h1Count = levels.filter((l) => l.n === 1).length
240
+ if (h1Count === 0) {
241
+ // Sections without an H1 can be valid IF this is a sub-fragment — but the
242
+ // first heading shouldn't deeper than H2 (otherwise the doc starts at H3+).
243
+ if (levels[0].n > 2) {
244
+ violations.push({
245
+ group: 'a11y',
246
+ id: 'a11y-heading-hierarchy',
247
+ location: `first heading is <${levels[0].tag}>`,
248
+ issue: `document starts at <${levels[0].tag}> with no H1/H2 above — heading outline is broken`,
249
+ candidateFix: `change the first <${levels[0].tag}> to <h2> (or add an <h1> ancestor in the page chrome — the orchestrator will know which)`,
250
+ severity: 'medium',
251
+ })
252
+ }
253
+ } else if (h1Count > 1) {
254
+ violations.push({
255
+ group: 'a11y',
256
+ id: 'a11y-heading-hierarchy',
257
+ location: `${h1Count} <h1> elements`,
258
+ issue: `multiple <h1> elements (${h1Count}) — page should have exactly one`,
259
+ candidateFix: `keep the most prominent <h1> and demote the others to <h2>`,
260
+ severity: 'medium',
261
+ })
262
+ }
263
+ // Detect skips (e.g. H2 → H4)
264
+ for (let i = 1; i < levels.length; i++) {
265
+ const prev = levels[i - 1]
266
+ const cur = levels[i]
267
+ if (cur.n > prev.n + 1) {
268
+ violations.push({
269
+ group: 'a11y',
270
+ id: 'a11y-heading-hierarchy',
271
+ location: `<${prev.tag}> → <${cur.tag}> (heading ${i + 1})`,
272
+ issue: `heading level skip from <${prev.tag}> to <${cur.tag}> — outline is jagged`,
273
+ candidateFix: `change the <${cur.tag}> after the <${prev.tag}> to <h${prev.n + 1}>`,
274
+ severity: 'low',
275
+ })
276
+ }
277
+ }
278
+ return violations
279
+ }
280
+
281
+ // a11y-button-type — <button> without type="button" submits parent forms
282
+ export function checkButtonType(ctx) {
283
+ const { $ } = ctx
284
+ const violations = []
285
+ $('button').each((_, el) => {
286
+ const $el = $(el)
287
+ const type = ($el.attr('type') || '').toLowerCase()
288
+ if (type === 'button' || type === 'submit' || type === 'reset') return
289
+ const cls = $el.attr('class') || ''
290
+ violations.push({
291
+ group: 'a11y',
292
+ id: 'a11y-button-type',
293
+ location: cls ? `<button class="${cls}">` : '<button>',
294
+ issue: '<button> without `type="button"` — defaults to type="submit" inside a form, can submit accidentally',
295
+ candidateFix: `add \`type="button"\` to the <button${cls ? ` class="${cls}"` : ''}>`,
296
+ severity: 'medium',
297
+ })
298
+ })
299
+ return violations
300
+ }
301
+
302
+ // a11y-nav-semantic — main navigation should be <nav>, not <div>
303
+ // Heuristic: an element with a class containing "nav" / "menu" / "header" that
304
+ // has an <ul> or multiple <a> children, and is itself a <div> (not <nav>),
305
+ // AND there is no <nav> ancestor wrapping it.
306
+ export function checkNavSemantic(ctx) {
307
+ const { $ } = ctx
308
+ const violations = []
309
+ $('div').each((_, el) => {
310
+ const $el = $(el)
311
+ const cls = String($el.attr('class') || '').toLowerCase()
312
+ if (!/(nav|menu|navigation)/.test(cls)) return
313
+ if (/(footer|sub|breadcrumb)/.test(cls)) return // skip secondary nav
314
+ if ($el.closest('nav').length) return
315
+ const links = $el.find('a').length
316
+ if (links < 2) return
317
+ violations.push({
318
+ group: 'a11y',
319
+ id: 'a11y-nav-semantic',
320
+ location: `<div class="${cls}"> with ${links} links`,
321
+ issue: 'navigation rendered as <div> — landmark roles missed by assistive tech',
322
+ candidateFix: `change \`<div class="${cls}">\` to \`<nav class="${cls}" aria-label="Main">\` (close the matching tag too)`,
323
+ severity: 'medium',
324
+ })
325
+ })
326
+ return violations
327
+ }
328
+
329
+ // perf-img-lazy — every non-hero <img> needs loading="lazy"
330
+ export function checkImgLazy(ctx) {
331
+ const { $ } = ctx
332
+ const violations = []
333
+ const hero = findHeroImg($)
334
+ $('img').each((_, el) => {
335
+ const $el = $(el)
336
+ if (hero && $el.is(hero)) return
337
+ const loading = String($el.attr('loading') || '').toLowerCase()
338
+ if (loading === 'lazy') return
339
+ if (loading === 'eager') return // explicit eager is intentional (LCP fallback)
340
+ const src = $el.attr('src') || $el.attr('data-src') || ''
341
+ violations.push({
342
+ group: 'performance',
343
+ id: 'perf-img-lazy',
344
+ location: src ? `<img src="${truncate(src, 60)}">` : '<img>',
345
+ issue: 'non-hero <img> without `loading="lazy"` — eager load wastes bandwidth on below-fold images',
346
+ candidateFix: `add \`loading="lazy"\` to the <img src="${truncate(src, 60)}">`,
347
+ severity: 'medium',
348
+ })
349
+ })
350
+ return violations
351
+ }
352
+
353
+ // perf-hero-preload — hero needs `<link rel="preload" as="image">`.
354
+ // Two-step detection (Pattern #25 of the plan):
355
+ // Step 1: search the scope CSS for a root-class rule with
356
+ // `background-image: url(...)`. If found, that IS the hero — verify
357
+ // a matching preload exists; flag against the bg URL otherwise.
358
+ // Step 2: only when no CSS-bg hero was found, fall back to <img> heroes.
359
+ // Skip the "first <img>" fallback tier — that's almost always the
360
+ // logo on builds where the hero uses CSS background-image.
361
+ // Step 3: if neither tier finds a hero, skip the check (no false positive).
362
+ export function checkHeroPreload(ctx) {
363
+ const { $ } = ctx
364
+ const violations = []
365
+ const preloads = collectPreloadHrefs($)
366
+
367
+ // Step 1: CSS background-image hero.
368
+ const cssHero = findHeroCssBackgroundImage(ctx.css || '')
369
+ if (cssHero) {
370
+ if (preloads.some((href) => href === cssHero.url)) return violations
371
+ violations.push({
372
+ group: 'performance',
373
+ id: 'perf-hero-preload',
374
+ location: `CSS \`background-image\` on \`${cssHero.selector}\``,
375
+ issue: 'hero CSS background-image without `<link rel="preload">` — LCP starts the network round-trip late',
376
+ candidateFix: `add \`<link rel="preload" as="image" href="${cssHero.url}">\` at the top of the fragment`,
377
+ severity: 'medium',
378
+ })
379
+ return violations
380
+ }
381
+
382
+ // Step 2: <img> hero. Skip the lowest "first <img>" tier — the logo case.
383
+ const heroPick = findHeroImgWithConfidence($)
384
+ if (!heroPick || heroPick.confidence === 'fallback') return violations
385
+ const heroSrc = heroPick.el.attr('src') || heroPick.el.attr('data-src') || ''
386
+ if (!heroSrc) return violations
387
+ if (preloads.some((href) => href === heroSrc)) return violations
388
+ violations.push({
389
+ group: 'performance',
390
+ id: 'perf-hero-preload',
391
+ location: `hero <img src="${truncate(heroSrc, 60)}">`,
392
+ issue: 'hero image without `<link rel="preload">` — LCP starts the network round-trip late',
393
+ candidateFix: `add \`<link rel="preload" as="image" href="${heroSrc}">\` at the top of the fragment`,
394
+ severity: 'medium',
395
+ })
396
+ return violations
397
+ }
398
+
399
+ // perf-font-display — Google Fonts URL must include &display=swap
400
+ export function checkFontDisplay(ctx) {
401
+ const { $ } = ctx
402
+ const violations = []
403
+ $('link[rel="stylesheet"][href*="fonts.googleapis.com"]').each((_, el) => {
404
+ const href = String($(el).attr('href') || '')
405
+ if (/[?&]display=swap\b/i.test(href)) return
406
+ violations.push({
407
+ group: 'performance',
408
+ id: 'perf-font-display',
409
+ location: `<link href="${truncate(href, 60)}">`,
410
+ issue: 'Google Fonts stylesheet without `&display=swap` — invisible text during font load (FOIT)',
411
+ candidateFix: `append \`&display=swap\` to the href of <link href="${truncate(href, 60)}">`,
412
+ severity: 'medium',
413
+ })
414
+ })
415
+ return violations
416
+ }
417
+
418
+ // perf-fetchpriority-hero — Shopify-only: hero needs fetchpriority="high"
419
+ export function checkFetchpriorityHero(ctx) {
420
+ if (ctx.preset !== 'shopify-section') return []
421
+ const { $ } = ctx
422
+ const hero = findHeroImg($)
423
+ if (!hero) return []
424
+ const fp = String(hero.attr('fetchpriority') || '').toLowerCase()
425
+ if (fp === 'high') return []
426
+ const heroSrc = hero.attr('src') || hero.attr('data-src') || ''
427
+ return [{
428
+ group: 'performance',
429
+ id: 'perf-fetchpriority-hero',
430
+ location: `hero <img src="${truncate(heroSrc, 60)}">`,
431
+ issue: 'hero <img> without `fetchpriority="high"` — Shopify themes benefit from the hint to prioritize LCP',
432
+ candidateFix: `add \`fetchpriority="high"\` to the hero <img src="${truncate(heroSrc, 60)}">`,
433
+ severity: 'low',
434
+ }]
435
+ }
436
+
437
+ // web-srcset-responsive — large <img> without srcset wastes bandwidth on small viewports
438
+ export function checkSrcsetResponsive(ctx) {
439
+ const { $ } = ctx
440
+ const violations = []
441
+ $('img').each((_, el) => {
442
+ const $el = $(el)
443
+ if ($el.attr('srcset')) return
444
+ const w = getImgWidth($el)
445
+ // Only flag images we know to be wide. Without a width attribute the file
446
+ // *might* still be huge, but flagging every <img> would be noise — let the
447
+ // LLM make a judgment call when explicit width is missing.
448
+ if (w == null || w <= 800) return
449
+ const src = $el.attr('src') || $el.attr('data-src') || ''
450
+ violations.push({
451
+ group: 'web-standards',
452
+ id: 'web-srcset-responsive',
453
+ location: src ? `<img src="${truncate(src, 60)}" width="${w}">` : `<img width="${w}">`,
454
+ issue: `<img> width=${w} without srcset — mobile viewports download the full-size file`,
455
+ candidateFix: `add \`srcset="${src} 1x, ${src} 2x"\` and \`sizes="(min-width: 1000px) 50vw, 100vw"\` to the <img> (the orchestrator can fill the actual srcset values from the inspection)`,
456
+ severity: 'low',
457
+ })
458
+ })
459
+ return violations
460
+ }
461
+
462
+ // web-modal-dialog — modals should be <dialog>, not <div>
463
+ export function checkModalDialog(ctx) {
464
+ const { $ } = ctx
465
+ const violations = []
466
+ $('div').each((_, el) => {
467
+ const $el = $(el)
468
+ const cls = String($el.attr('class') || '').toLowerCase()
469
+ const role = String($el.attr('role') || '').toLowerCase()
470
+ const isModalCandidate = role === 'dialog' || /(^|[\s_-])modal([\s_-]|$)/.test(cls)
471
+ if (!isModalCandidate) return
472
+ if ($el.closest('dialog').length) return
473
+ violations.push({
474
+ group: 'web-standards',
475
+ id: 'web-modal-dialog',
476
+ location: `<div class="${cls}"${role ? ` role="${role}"` : ''}>`,
477
+ issue: 'modal as <div> instead of <dialog> — loses native focus trap, ESC handling, backdrop',
478
+ candidateFix: `change \`<div class="${cls}"${role ? ` role="${role}"` : ''}>\` to \`<dialog class="${cls}">\` and open with \`dialog.showModal()\`, close with \`dialog.close()\``,
479
+ severity: 'medium',
480
+ })
481
+ })
482
+ return violations
483
+ }
484
+
485
+ // web-preconnect-fonts — Google Fonts stylesheet without a preceding preconnect
486
+ export function checkPreconnectFonts(ctx) {
487
+ const { $ } = ctx
488
+ const violations = []
489
+ const stylesheets = $('link[rel="stylesheet"][href*="fonts.googleapis.com"]')
490
+ if (stylesheets.length === 0) return violations
491
+ const preconnects = $('link[rel="preconnect"]').map((_, el) => String($(el).attr('href') || '')).get()
492
+ const hasGoogleApis = preconnects.some((h) => /fonts\.googleapis\.com/.test(h))
493
+ const hasGstatic = preconnects.some((h) => /fonts\.gstatic\.com/.test(h))
494
+ if (hasGoogleApis && hasGstatic) return violations
495
+ const missing = []
496
+ if (!hasGoogleApis) missing.push('https://fonts.googleapis.com')
497
+ if (!hasGstatic) missing.push('https://fonts.gstatic.com')
498
+ violations.push({
499
+ group: 'performance',
500
+ id: 'web-preconnect-fonts',
501
+ location: 'fragment <head>-position links',
502
+ issue: `Google Fonts loaded without <link rel="preconnect"> — DNS+TLS handshake delays font request${missing.length > 1 ? 's for ' + missing.join(' and ') : ' for ' + missing[0]}`,
503
+ candidateFix: `add ${missing.map((u) => `\`<link rel="preconnect" href="${u}"${u.includes('gstatic') ? ' crossorigin' : ''}>\``).join(' and ')} before the Google Fonts <link rel="stylesheet">`,
504
+ severity: 'low',
505
+ })
506
+ return violations
507
+ }
508
+
509
+ export const DESIGN_QUALITY_CHECKS = [
510
+ checkButtonAria,
511
+ checkImgAlt,
512
+ checkHeadingHierarchy,
513
+ checkButtonType,
514
+ checkNavSemantic,
515
+ checkImgLazy,
516
+ checkHeroPreload,
517
+ checkFontDisplay,
518
+ checkFetchpriorityHero,
519
+ checkSrcsetResponsive,
520
+ checkModalDialog,
521
+ checkPreconnectFonts,
522
+ ]
523
+
524
+ export function runDesignQuality(ctx) {
525
+ const out = []
526
+ for (const fn of DESIGN_QUALITY_CHECKS) {
527
+ try {
528
+ out.push(...fn(ctx))
529
+ } catch (err) {
530
+ out.push({
531
+ group: 'design-quality',
532
+ id: fn.name || 'unknown',
533
+ location: 'check',
534
+ issue: `check threw: ${err.message}`,
535
+ candidateFix: 'inspect the input file — it may be malformed HTML',
536
+ severity: 'low',
537
+ })
538
+ }
539
+ }
540
+ return sortBySeverity(out)
541
+ }