@argus-design/scorer 2026.4.12

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 (3) hide show
  1. package/dist/index.js +2461 -0
  2. package/dist/index.mjs +2373 -0
  3. package/package.json +29 -0
package/dist/index.js ADDED
@@ -0,0 +1,2461 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ BUILT_IN_RULES: () => BUILT_IN_RULES,
24
+ RULE_COUNT: () => RULE_COUNT,
25
+ audit: () => audit,
26
+ borderRadiusConsistency: () => borderRadiusConsistency,
27
+ colorConsistency: () => colorConsistency,
28
+ colorOnlyInformation: () => colorOnlyInformation,
29
+ colorPaletteSize: () => colorPaletteSize,
30
+ compare: () => compare,
31
+ containerPadding: () => containerPadding,
32
+ contentDensity: () => contentDensity,
33
+ contrastCompliance: () => contrastCompliance,
34
+ ctaDominance: () => ctaDominance,
35
+ cursorAffordance: () => cursorAffordance,
36
+ customPropertyUsage: () => customPropertyUsage,
37
+ documentLanguage: () => documentLanguage,
38
+ emptyInteractiveElements: () => emptyInteractiveElements,
39
+ flexAlignmentExplicit: () => flexAlignmentExplicit,
40
+ fontFamilyCount: () => fontFamilyCount,
41
+ fontSizeVariety: () => fontSizeVariety,
42
+ fontWeightUsage: () => fontWeightUsage,
43
+ formatReport: () => formatReport,
44
+ generateAuditHTMLReport: () => generateAuditHTMLReport,
45
+ generateHTMLReport: () => generateHTMLReport,
46
+ getAllRuleIds: () => getAllRuleIds,
47
+ getRuleById: () => getRuleById,
48
+ getRuleCounts: () => getRuleCounts,
49
+ getRulesByCategory: () => getRulesByCategory,
50
+ headingLevelStructure: () => headingLevelStructure,
51
+ headingSizeProgression: () => headingSizeProgression,
52
+ iconButtonSize: () => iconButtonSize,
53
+ imageAltText: () => imageAltText,
54
+ imageAspectConsistency: () => imageAspectConsistency,
55
+ imageContainment: () => imageContainment,
56
+ imageDimensions: () => imageDimensions,
57
+ inputAffordance: () => inputAffordance,
58
+ interactiveStateConsistency: () => interactiveStateConsistency,
59
+ landmarkPresence: () => landmarkPresence,
60
+ layoutNestingDepth: () => layoutNestingDepth,
61
+ lineHeightReadability: () => lineHeightReadability,
62
+ lineLength: () => lineLength,
63
+ linkDistinguishability: () => linkDistinguishability,
64
+ overflowDetection: () => overflowDetection,
65
+ positioningAudit: () => positioningAudit,
66
+ pureBlackAvoidance: () => pureBlackAvoidance,
67
+ score: () => score,
68
+ sectionBreathing: () => sectionBreathing,
69
+ semanticInteractive: () => semanticInteractive,
70
+ semanticLists: () => semanticLists,
71
+ semanticTextElements: () => semanticTextElements,
72
+ shadowConsistency: () => shadowConsistency,
73
+ spacingConsistency: () => spacingConsistency,
74
+ spacingScale: () => spacingScale,
75
+ spacingVariety: () => spacingVariety,
76
+ suggest: () => suggest,
77
+ tableStructure: () => tableStructure,
78
+ textEdgePadding: () => textEdgePadding,
79
+ touchTargetSize: () => touchTargetSize,
80
+ transitionPresence: () => transitionPresence,
81
+ transitionTiming: () => transitionTiming,
82
+ typeScale: () => typeScale,
83
+ visualWeightDistribution: () => visualWeightDistribution,
84
+ zIndexSanity: () => zIndexSanity
85
+ });
86
+ module.exports = __toCommonJS(index_exports);
87
+
88
+ // src/core/engine.ts
89
+ var import_protocol4 = require("@argus-design/protocol");
90
+
91
+ // src/rules/spacing.ts
92
+ var import_protocol = require("@argus-design/protocol");
93
+ var spacingScale = {
94
+ id: "spacing-scale",
95
+ name: "Spacing Scale Adherence",
96
+ category: "spacing",
97
+ description: "Checks that margin and padding values align to a spacing scale (8px grid by default)",
98
+ evaluate: (snapshot) => {
99
+ const findings = [];
100
+ const offGrid = [];
101
+ for (const el of snapshot.elements) {
102
+ if (!el.isVisible) continue;
103
+ const spacingProps = [
104
+ { name: "margin-top", value: el.margin.top },
105
+ { name: "margin-right", value: el.margin.right },
106
+ { name: "margin-bottom", value: el.margin.bottom },
107
+ { name: "margin-left", value: el.margin.left },
108
+ { name: "padding-top", value: el.padding.top },
109
+ { name: "padding-right", value: el.padding.right },
110
+ { name: "padding-bottom", value: el.padding.bottom },
111
+ { name: "padding-left", value: el.padding.left }
112
+ ];
113
+ for (const prop of spacingProps) {
114
+ if (prop.value <= 0) continue;
115
+ const rounded = Math.round(prop.value);
116
+ if (!import_protocol.SPACING_SCALE.includes(rounded) && rounded > 2) {
117
+ offGrid.push({
118
+ selector: el.selector,
119
+ property: prop.name,
120
+ value: rounded
121
+ });
122
+ }
123
+ }
124
+ }
125
+ if (offGrid.length > 0) {
126
+ const significant = offGrid.filter((o) => {
127
+ const nearest = import_protocol.SPACING_SCALE.reduce(
128
+ (prev, curr) => Math.abs(curr - o.value) < Math.abs(prev - o.value) ? curr : prev
129
+ );
130
+ return Math.abs(o.value - nearest) > 2;
131
+ });
132
+ if (significant.length > 0) {
133
+ const severity = significant.length > 10 ? "error" : "warning";
134
+ const impact = Math.min(25, Math.round(significant.length * 1.5));
135
+ findings.push({
136
+ ruleId: "spacing-scale",
137
+ severity,
138
+ category: "spacing",
139
+ selector: significant[0].selector,
140
+ message: `${significant.length} spacing value(s) don't align to the 8px grid`,
141
+ measured: `${significant.length} off-grid values`,
142
+ expected: "All spacing values on 8px grid (0, 4, 8, 12, 16, 24, 32, 48, 64)",
143
+ suggestion: `Nearest grid values: ${significant.slice(0, 3).map((s) => {
144
+ const nearest = import_protocol.SPACING_SCALE.reduce(
145
+ (prev, curr) => Math.abs(curr - s.value) < Math.abs(prev - s.value) ? curr : prev
146
+ );
147
+ return `${s.value}px \u2192 ${nearest}px`;
148
+ }).join(", ")}`,
149
+ impact
150
+ });
151
+ }
152
+ }
153
+ if (findings.length === 0) {
154
+ findings.push({
155
+ ruleId: "spacing-scale",
156
+ severity: "pass",
157
+ category: "spacing",
158
+ selector: "body",
159
+ message: "All spacing values align to the spacing scale",
160
+ measured: "0 off-grid values",
161
+ expected: "0 off-grid values",
162
+ suggestion: "",
163
+ impact: 0
164
+ });
165
+ }
166
+ return findings;
167
+ }
168
+ };
169
+ var spacingConsistency = {
170
+ id: "spacing-consistency",
171
+ name: "Spacing Consistency",
172
+ category: "spacing",
173
+ description: "Checks that gaps between sibling elements are uniform",
174
+ evaluate: (snapshot) => {
175
+ const findings = [];
176
+ const parentMap = /* @__PURE__ */ new Map();
177
+ for (const el of snapshot.elements) {
178
+ if (!el.parentSelector || !el.isVisible) continue;
179
+ const siblings = parentMap.get(el.parentSelector) ?? [];
180
+ siblings.push(el);
181
+ parentMap.set(el.parentSelector, siblings);
182
+ }
183
+ for (const [parentSelector, children] of parentMap) {
184
+ if (children.length < 3) continue;
185
+ const sorted = [...children].sort((a, b) => a.box.y - b.box.y);
186
+ const gaps = [];
187
+ for (let i = 1; i < sorted.length; i++) {
188
+ const gap = sorted[i].box.y - (sorted[i - 1].box.y + sorted[i - 1].box.height);
189
+ if (gap >= 0 && gap < 200) gaps.push(Math.round(gap));
190
+ }
191
+ if (gaps.length < 2) continue;
192
+ const uniqueGaps = [...new Set(gaps)];
193
+ if (uniqueGaps.length > 1) {
194
+ const mode = findMode(gaps);
195
+ const inconsistent = gaps.filter((g) => g !== mode);
196
+ if (inconsistent.length > 0) {
197
+ findings.push({
198
+ ruleId: "spacing-consistency",
199
+ severity: uniqueGaps.length > 3 ? "error" : "warning",
200
+ category: "spacing",
201
+ selector: parentSelector,
202
+ message: `Inconsistent vertical gaps between ${children.length} siblings: ${uniqueGaps.join("px, ")}px`,
203
+ measured: `${uniqueGaps.length} distinct gap values`,
204
+ expected: "1 consistent gap value",
205
+ suggestion: `Set uniform gap of ${mode}px (most common value) on parent container`,
206
+ impact: Math.min(15, uniqueGaps.length * 4)
207
+ });
208
+ }
209
+ }
210
+ }
211
+ if (findings.length === 0) {
212
+ findings.push({
213
+ ruleId: "spacing-consistency",
214
+ severity: "pass",
215
+ category: "spacing",
216
+ selector: "body",
217
+ message: "Sibling element spacing is consistent",
218
+ measured: "Uniform gaps",
219
+ expected: "Uniform gaps",
220
+ suggestion: "",
221
+ impact: 0
222
+ });
223
+ }
224
+ return findings;
225
+ }
226
+ };
227
+ var spacingVariety = {
228
+ id: "spacing-variety",
229
+ name: "Spacing Value Variety",
230
+ category: "spacing",
231
+ description: "Checks that the page uses a reasonable number of distinct spacing values (not too many, not too few)",
232
+ evaluate: (snapshot) => {
233
+ const findings = [];
234
+ const distinct = snapshot.spacingValues.length;
235
+ if (distinct > 15) {
236
+ findings.push({
237
+ ruleId: "spacing-variety",
238
+ severity: "warning",
239
+ category: "spacing",
240
+ selector: "body",
241
+ message: `${distinct} distinct spacing values found \u2014 suggests inconsistent spacing system`,
242
+ measured: distinct,
243
+ expected: "5\u201312 distinct values",
244
+ suggestion: "Consolidate spacing values to a consistent scale. Use CSS custom properties for spacing tokens.",
245
+ impact: Math.min(15, Math.round((distinct - 12) * 1.5))
246
+ });
247
+ } else {
248
+ findings.push({
249
+ ruleId: "spacing-variety",
250
+ severity: "pass",
251
+ category: "spacing",
252
+ selector: "body",
253
+ message: `${distinct} distinct spacing values \u2014 within reasonable range`,
254
+ measured: distinct,
255
+ expected: "5\u201312 distinct values",
256
+ suggestion: "",
257
+ impact: 0
258
+ });
259
+ }
260
+ return findings;
261
+ }
262
+ };
263
+ var containerPadding = {
264
+ id: "container-padding",
265
+ name: "Container Padding Consistency",
266
+ category: "spacing",
267
+ description: "Checks that containers have consistent internal padding",
268
+ evaluate: (snapshot) => {
269
+ const findings = [];
270
+ const containers = snapshot.elements.filter(
271
+ (el) => el.isVisible && el.childCount >= 2 && el.depth <= 4
272
+ );
273
+ const asymmetric = [];
274
+ for (const el of containers) {
275
+ const { top, right, bottom, left } = el.padding;
276
+ if (left > 0 && right > 0 && Math.abs(left - right) > 2) {
277
+ asymmetric.push(el.selector);
278
+ }
279
+ }
280
+ if (asymmetric.length > 3) {
281
+ findings.push({
282
+ ruleId: "container-padding",
283
+ severity: "info",
284
+ category: "spacing",
285
+ selector: asymmetric[0],
286
+ message: `${asymmetric.length} container(s) with asymmetric horizontal padding`,
287
+ measured: `${asymmetric.length} asymmetric containers`,
288
+ expected: "Symmetric horizontal padding on containers",
289
+ suggestion: "Use equal left and right padding on container elements",
290
+ impact: Math.min(10, asymmetric.length)
291
+ });
292
+ }
293
+ return findings;
294
+ }
295
+ };
296
+ function findMode(values) {
297
+ const counts = /* @__PURE__ */ new Map();
298
+ for (const v of values) {
299
+ counts.set(v, (counts.get(v) ?? 0) + 1);
300
+ }
301
+ let maxCount = 0;
302
+ let mode = values[0];
303
+ for (const [val, count] of counts) {
304
+ if (count > maxCount) {
305
+ maxCount = count;
306
+ mode = val;
307
+ }
308
+ }
309
+ return mode;
310
+ }
311
+
312
+ // src/rules/whitespace.ts
313
+ var contentDensity = {
314
+ id: "content-density",
315
+ name: "Content Density",
316
+ category: "spacing",
317
+ description: "Checks that content density is balanced \u2014 not too cramped, not too sparse",
318
+ evaluate: (snapshot) => {
319
+ const findings = [];
320
+ const vh = snapshot.viewport.height;
321
+ const aboveFold = snapshot.elements.filter(
322
+ (el) => el.isVisible && el.box.y < vh && el.depth <= 4
323
+ );
324
+ if (aboveFold.length > 60) {
325
+ findings.push({
326
+ ruleId: "content-density",
327
+ severity: "warning",
328
+ category: "spacing",
329
+ selector: "body",
330
+ message: `${aboveFold.length} visible elements above the fold \u2014 page feels dense and overwhelming`,
331
+ measured: `${aboveFold.length} elements in viewport`,
332
+ expected: "20\u201350 elements above the fold for balanced density",
333
+ suggestion: "Reduce visual complexity above the fold. Prioritize key content and move secondary info below.",
334
+ impact: Math.min(12, Math.round((aboveFold.length - 50) / 5))
335
+ });
336
+ } else if (aboveFold.length < 5 && snapshot.meta.visibleElements > 10) {
337
+ findings.push({
338
+ ruleId: "content-density",
339
+ severity: "info",
340
+ category: "spacing",
341
+ selector: "body",
342
+ message: `Only ${aboveFold.length} elements above the fold \u2014 page may feel empty or lacking hierarchy`,
343
+ measured: `${aboveFold.length} elements in viewport`,
344
+ expected: "10+ elements above the fold",
345
+ suggestion: "Ensure meaningful content is visible without scrolling",
346
+ impact: 5
347
+ });
348
+ }
349
+ return findings;
350
+ }
351
+ };
352
+ var sectionBreathing = {
353
+ id: "section-breathing",
354
+ name: "Section Breathing Room",
355
+ category: "spacing",
356
+ description: "Checks that major sections have adequate vertical spacing between them",
357
+ evaluate: (snapshot) => {
358
+ const findings = [];
359
+ const sections = snapshot.elements.filter(
360
+ (el) => el.isVisible && el.depth <= 2 && el.box.height > 100 && el.childCount >= 2
361
+ );
362
+ if (sections.length < 2) return findings;
363
+ const sorted = [...sections].sort((a, b) => a.box.y - b.box.y);
364
+ const tightGaps = [];
365
+ for (let i = 1; i < sorted.length; i++) {
366
+ const gap = sorted[i].box.y - (sorted[i - 1].box.y + sorted[i - 1].box.height);
367
+ if (gap >= 0 && gap < 32) {
368
+ tightGaps.push({
369
+ between: `${sorted[i - 1].selector} \u2192 ${sorted[i].selector}`,
370
+ gap: Math.round(gap)
371
+ });
372
+ }
373
+ }
374
+ if (tightGaps.length > 0) {
375
+ findings.push({
376
+ ruleId: "section-breathing",
377
+ severity: "warning",
378
+ category: "spacing",
379
+ selector: sorted[0].selector,
380
+ message: `${tightGaps.length} section gap(s) under 32px \u2014 sections feel cramped`,
381
+ measured: `${tightGaps.length} tight gaps (${tightGaps.map((g) => `${g.gap}px`).join(", ")})`,
382
+ expected: "Minimum 48\u201364px between major sections",
383
+ suggestion: "Add margin-bottom: 64px (or var(--space-16)) between major page sections",
384
+ impact: Math.min(12, tightGaps.length * 4)
385
+ });
386
+ }
387
+ return findings;
388
+ }
389
+ };
390
+ var textEdgePadding = {
391
+ id: "text-edge-padding",
392
+ name: "Text Edge Padding",
393
+ category: "spacing",
394
+ description: "Checks that text content has sufficient padding from container edges",
395
+ evaluate: (snapshot) => {
396
+ const findings = [];
397
+ const cramped = [];
398
+ for (const el of snapshot.elements) {
399
+ if (!el.isVisible || !el.textContent || el.textContent.length < 20) continue;
400
+ if (el.depth < 2) continue;
401
+ if (el.box.x < 8 && el.box.x >= 0) {
402
+ cramped.push(el.selector);
403
+ }
404
+ const parent = snapshot.elements.find((p) => p.selector === el.parentSelector);
405
+ if (parent) {
406
+ const leftPadding = el.box.x - parent.box.x;
407
+ if (leftPadding < 8 && leftPadding >= 0 && parent.padding.left < 8) {
408
+ cramped.push(el.selector);
409
+ }
410
+ }
411
+ }
412
+ const unique = [...new Set(cramped)];
413
+ if (unique.length > 3) {
414
+ findings.push({
415
+ ruleId: "text-edge-padding",
416
+ severity: "warning",
417
+ category: "spacing",
418
+ selector: unique[0],
419
+ message: `${unique.length} text element(s) with less than 8px padding from container edge`,
420
+ measured: `${unique.length} elements with tight edge padding`,
421
+ expected: "Minimum 16px padding from container edges",
422
+ suggestion: "Add padding to containers so text never touches edges. Use min 16px horizontal padding.",
423
+ impact: Math.min(10, unique.length * 2)
424
+ });
425
+ }
426
+ return findings;
427
+ }
428
+ };
429
+ var lineLength = {
430
+ id: "line-length",
431
+ name: "Line Length (Characters Per Line)",
432
+ category: "typography",
433
+ description: "Checks that text blocks have readable line lengths (45\u201385 characters)",
434
+ evaluate: (snapshot) => {
435
+ const findings = [];
436
+ const tooWide = [];
437
+ for (const el of snapshot.elements) {
438
+ if (!el.isVisible || !el.textContent || el.textContent.length < 50) continue;
439
+ if (/^h[1-6]$/.test(el.tagName)) continue;
440
+ const fontSize = parseFloat(el.computedStyles.fontSize);
441
+ if (fontSize <= 0 || fontSize > 22) continue;
442
+ const avgCharWidth = fontSize * 0.5;
443
+ const charsPerLine = el.box.width / avgCharWidth;
444
+ if (charsPerLine > 90) {
445
+ tooWide.push(el.selector);
446
+ }
447
+ }
448
+ if (tooWide.length > 0) {
449
+ findings.push({
450
+ ruleId: "line-length",
451
+ severity: "warning",
452
+ category: "typography",
453
+ selector: tooWide[0],
454
+ message: `${tooWide.length} text block(s) exceed 90 characters per line \u2014 hurts readability`,
455
+ measured: `${tooWide.length} wide text blocks`,
456
+ expected: "45\u201375 characters per line for body text",
457
+ suggestion: "Constrain text containers with max-width: 65ch or max-width: 680px",
458
+ impact: Math.min(15, tooWide.length * 3)
459
+ });
460
+ }
461
+ return findings;
462
+ }
463
+ };
464
+
465
+ // src/rules/typography.ts
466
+ var import_protocol2 = require("@argus-design/protocol");
467
+ var typeScale = {
468
+ id: "type-scale",
469
+ name: "Typographic Scale",
470
+ category: "typography",
471
+ description: "Checks that font sizes follow a mathematical typographic scale ratio",
472
+ evaluate: (snapshot) => {
473
+ const findings = [];
474
+ const sizes = snapshot.typographyScale.map((t) => parseFloat(t.fontSize)).filter((s) => s > 0).sort((a, b) => a - b);
475
+ const unique = [...new Set(sizes)];
476
+ if (unique.length < 3) return findings;
477
+ let bestRatio = "";
478
+ let bestScore = 0;
479
+ for (const [name, ratio] of Object.entries(import_protocol2.TYPE_SCALE_RATIOS)) {
480
+ let matches = 0;
481
+ const baseSize = unique[0];
482
+ for (const size of unique) {
483
+ const steps = Math.log(size / baseSize) / Math.log(ratio);
484
+ if (Math.abs(steps - Math.round(steps)) < 0.15) {
485
+ matches++;
486
+ }
487
+ }
488
+ const score2 = matches / unique.length;
489
+ if (score2 > bestScore) {
490
+ bestScore = score2;
491
+ bestRatio = name;
492
+ }
493
+ }
494
+ if (bestScore < 0.5) {
495
+ findings.push({
496
+ ruleId: "type-scale",
497
+ severity: "warning",
498
+ category: "typography",
499
+ selector: "body",
500
+ message: `Font sizes don't follow a consistent typographic scale. Found ${unique.length} distinct sizes: ${unique.map((s) => `${s}px`).join(", ")}`,
501
+ measured: `${unique.length} sizes, best scale fit: ${Math.round(bestScore * 100)}%`,
502
+ expected: "Sizes following a mathematical ratio (e.g., 1.25 major third)",
503
+ suggestion: `Consider adopting a ${bestRatio || "major third (1.25)"} scale from your base size of ${unique[0]}px`,
504
+ impact: Math.min(20, Math.round((1 - bestScore) * 25))
505
+ });
506
+ }
507
+ return findings;
508
+ }
509
+ };
510
+ var fontSizeVariety = {
511
+ id: "font-size-variety",
512
+ name: "Font Size Variety",
513
+ category: "typography",
514
+ description: "Checks the page uses a reasonable number of distinct font sizes",
515
+ evaluate: (snapshot) => {
516
+ const findings = [];
517
+ const distinct = new Set(
518
+ snapshot.typographyScale.map((t) => t.fontSize)
519
+ ).size;
520
+ if (distinct > 8) {
521
+ findings.push({
522
+ ruleId: "font-size-variety",
523
+ severity: "warning",
524
+ category: "typography",
525
+ selector: "body",
526
+ message: `${distinct} distinct font sizes found \u2014 too many for a coherent type system`,
527
+ measured: distinct,
528
+ expected: "4\u20137 distinct font sizes",
529
+ suggestion: "Consolidate to a type scale with 5-6 sizes: caption, body, subheading, heading, display",
530
+ impact: Math.min(15, (distinct - 7) * 3)
531
+ });
532
+ } else {
533
+ findings.push({
534
+ ruleId: "font-size-variety",
535
+ severity: "pass",
536
+ category: "typography",
537
+ selector: "body",
538
+ message: `${distinct} distinct font sizes \u2014 good typographic discipline`,
539
+ measured: distinct,
540
+ expected: "4\u20137 distinct font sizes",
541
+ suggestion: "",
542
+ impact: 0
543
+ });
544
+ }
545
+ return findings;
546
+ }
547
+ };
548
+ var fontWeightUsage = {
549
+ id: "font-weight-usage",
550
+ name: "Font Weight Usage",
551
+ category: "typography",
552
+ description: "Checks that font weights create clear visual hierarchy",
553
+ evaluate: (snapshot) => {
554
+ const findings = [];
555
+ const weights = new Set(
556
+ snapshot.typographyScale.map((t) => t.fontWeight)
557
+ );
558
+ if (weights.size > 4) {
559
+ findings.push({
560
+ ruleId: "font-weight-usage",
561
+ severity: "info",
562
+ category: "typography",
563
+ selector: "body",
564
+ message: `${weights.size} distinct font weights used (${[...weights].join(", ")})`,
565
+ measured: weights.size,
566
+ expected: "2\u20133 distinct weights (regular, medium/semibold, bold)",
567
+ suggestion: "Simplify to 2-3 weights for clearer hierarchy: 400 (body), 500/600 (emphasis), 700 (headings)",
568
+ impact: Math.min(10, (weights.size - 3) * 3)
569
+ });
570
+ }
571
+ return findings;
572
+ }
573
+ };
574
+ var lineHeightReadability = {
575
+ id: "line-height",
576
+ name: "Line Height Readability",
577
+ category: "typography",
578
+ description: "Checks that line heights fall within readable ranges",
579
+ evaluate: (snapshot) => {
580
+ const findings = [];
581
+ const tight = [];
582
+ const loose = [];
583
+ for (const el of snapshot.elements) {
584
+ if (!el.isVisible || !el.textContent || el.textContent.length < 10) continue;
585
+ const fontSize = parseFloat(el.computedStyles.fontSize);
586
+ const lineHeight = parseFloat(el.computedStyles.lineHeight);
587
+ if (fontSize <= 0 || lineHeight <= 0) continue;
588
+ const ratio = lineHeight / fontSize;
589
+ if (fontSize <= 20) {
590
+ if (ratio < 1.3) tight.push(el.selector);
591
+ if (ratio > 2.2) loose.push(el.selector);
592
+ }
593
+ }
594
+ if (tight.length > 0) {
595
+ findings.push({
596
+ ruleId: "line-height",
597
+ severity: "warning",
598
+ category: "typography",
599
+ selector: tight[0],
600
+ message: `${tight.length} text element(s) with tight line-height (< 1.3x font size)`,
601
+ measured: `${tight.length} elements below 1.3 ratio`,
602
+ expected: "Line-height 1.4\u20131.8x for body text",
603
+ suggestion: "Increase line-height to at least 1.5 for readable body text",
604
+ impact: Math.min(15, tight.length * 3)
605
+ });
606
+ }
607
+ if (findings.length === 0) {
608
+ findings.push({
609
+ ruleId: "line-height",
610
+ severity: "pass",
611
+ category: "typography",
612
+ selector: "body",
613
+ message: "Line heights are within readable ranges",
614
+ measured: "All within range",
615
+ expected: "1.3\u20132.0 ratio",
616
+ suggestion: "",
617
+ impact: 0
618
+ });
619
+ }
620
+ return findings;
621
+ }
622
+ };
623
+ var fontFamilyCount = {
624
+ id: "font-family-count",
625
+ name: "Font Family Count",
626
+ category: "typography",
627
+ description: "Checks that the page uses a reasonable number of font families",
628
+ evaluate: (snapshot) => {
629
+ const findings = [];
630
+ const count = snapshot.meta.fontFamilyCount;
631
+ if (count > 3) {
632
+ findings.push({
633
+ ruleId: "font-family-count",
634
+ severity: "warning",
635
+ category: "typography",
636
+ selector: "body",
637
+ message: `${count} distinct font families used \u2014 consider consolidating`,
638
+ measured: count,
639
+ expected: "1\u20133 font families",
640
+ suggestion: "Use one display font and one body font. A third for monospace if needed.",
641
+ impact: Math.min(12, (count - 3) * 4)
642
+ });
643
+ }
644
+ return findings;
645
+ }
646
+ };
647
+
648
+ // src/rules/color.ts
649
+ var colorPaletteSize = {
650
+ id: "color-palette-size",
651
+ name: "Color Palette Size",
652
+ category: "color",
653
+ description: "Checks that the page uses a focused color palette",
654
+ evaluate: (snapshot) => {
655
+ const findings = [];
656
+ const textColors = snapshot.colorPalette.filter((c) => c.usage === "text");
657
+ const bgColors = snapshot.colorPalette.filter((c) => c.usage === "background");
658
+ if (textColors.length > 8) {
659
+ findings.push({
660
+ ruleId: "color-palette-size",
661
+ severity: "warning",
662
+ category: "color",
663
+ selector: "body",
664
+ message: `${textColors.length} distinct text colors \u2014 suggests an unfocused palette`,
665
+ measured: textColors.length,
666
+ expected: "3\u20136 text colors (primary, secondary, muted, accent, error, success)",
667
+ suggestion: "Define text color tokens and apply them consistently via CSS custom properties",
668
+ impact: Math.min(15, (textColors.length - 6) * 2)
669
+ });
670
+ }
671
+ if (bgColors.length > 10) {
672
+ findings.push({
673
+ ruleId: "color-palette-size",
674
+ severity: "info",
675
+ category: "color",
676
+ selector: "body",
677
+ message: `${bgColors.length} distinct background colors found`,
678
+ measured: bgColors.length,
679
+ expected: "4\u20138 background colors",
680
+ suggestion: "Consolidate background colors to a defined surface palette",
681
+ impact: Math.min(10, (bgColors.length - 8) * 1.5)
682
+ });
683
+ }
684
+ if (findings.length === 0) {
685
+ findings.push({
686
+ ruleId: "color-palette-size",
687
+ severity: "pass",
688
+ category: "color",
689
+ selector: "body",
690
+ message: "Color palette is focused and consistent",
691
+ measured: `${textColors.length} text, ${bgColors.length} background colors`,
692
+ expected: "Focused palette",
693
+ suggestion: "",
694
+ impact: 0
695
+ });
696
+ }
697
+ return findings;
698
+ }
699
+ };
700
+ var contrastCompliance = {
701
+ id: "contrast-compliance",
702
+ name: "Contrast Compliance",
703
+ category: "color",
704
+ description: "Checks WCAG contrast ratios for text elements",
705
+ evaluate: (snapshot) => {
706
+ const findings = [];
707
+ const failing = [];
708
+ for (const el of snapshot.elements) {
709
+ if (!el.isVisible || !el.textContent) continue;
710
+ if (el.accessibility.wcagLevel === "fail") {
711
+ failing.push(el.selector);
712
+ }
713
+ }
714
+ if (failing.length > 0) {
715
+ findings.push({
716
+ ruleId: "contrast-compliance",
717
+ severity: "error",
718
+ category: "color",
719
+ selector: failing[0],
720
+ message: `${failing.length} element(s) fail WCAG AA contrast requirements`,
721
+ measured: `${failing.length} failures`,
722
+ expected: "0 contrast failures",
723
+ suggestion: "Increase text-to-background contrast ratio to at least 4.5:1 (3:1 for large text)",
724
+ impact: Math.min(25, failing.length * 5)
725
+ });
726
+ } else {
727
+ findings.push({
728
+ ruleId: "contrast-compliance",
729
+ severity: "pass",
730
+ category: "color",
731
+ selector: "body",
732
+ message: "All text elements pass WCAG AA contrast requirements",
733
+ measured: "0 failures",
734
+ expected: "0 failures",
735
+ suggestion: "",
736
+ impact: 0
737
+ });
738
+ }
739
+ return findings;
740
+ }
741
+ };
742
+ var colorConsistency = {
743
+ id: "color-consistency",
744
+ name: "Color Value Consistency",
745
+ category: "color",
746
+ description: "Checks for near-duplicate colors that should be unified",
747
+ evaluate: (snapshot) => {
748
+ const findings = [];
749
+ const colors = snapshot.colorPalette.filter((c) => c.count >= 2);
750
+ const nearDupes = [];
751
+ for (let i = 0; i < colors.length; i++) {
752
+ for (let j = i + 1; j < colors.length; j++) {
753
+ if (areNearDuplicate(colors[i].value, colors[j].value)) {
754
+ nearDupes.push({ a: colors[i].value, b: colors[j].value });
755
+ }
756
+ }
757
+ }
758
+ if (nearDupes.length > 0) {
759
+ findings.push({
760
+ ruleId: "color-consistency",
761
+ severity: "info",
762
+ category: "color",
763
+ selector: "body",
764
+ message: `${nearDupes.length} near-duplicate color pair(s) found that could be unified`,
765
+ measured: `${nearDupes.length} near-duplicates`,
766
+ expected: "0 near-duplicate colors",
767
+ suggestion: `Consider unifying: ${nearDupes.slice(0, 3).map((d) => `${d.a} \u2248 ${d.b}`).join("; ")}`,
768
+ impact: Math.min(8, nearDupes.length * 2)
769
+ });
770
+ }
771
+ return findings;
772
+ }
773
+ };
774
+ var pureBlackAvoidance = {
775
+ id: "pure-black-avoidance",
776
+ name: "Pure Black Text Avoidance",
777
+ category: "color",
778
+ description: "Checks that text doesn't use pure black (#000000) on white backgrounds",
779
+ evaluate: (snapshot) => {
780
+ const findings = [];
781
+ const pureBlack = [];
782
+ for (const el of snapshot.elements) {
783
+ if (!el.isVisible || !el.textContent) continue;
784
+ const color = el.computedStyles.color;
785
+ if (color === "rgb(0, 0, 0)" || color === "#000000" || color === "#000") {
786
+ pureBlack.push(el.selector);
787
+ }
788
+ }
789
+ if (pureBlack.length > 5) {
790
+ findings.push({
791
+ ruleId: "pure-black-avoidance",
792
+ severity: "info",
793
+ category: "color",
794
+ selector: pureBlack[0],
795
+ message: `${pureBlack.length} element(s) use pure black (#000) text \u2014 can feel harsh on white backgrounds`,
796
+ measured: `${pureBlack.length} elements with #000`,
797
+ expected: "Softened dark color (e.g., #1a1a1a, #111827)",
798
+ suggestion: "Use a slightly softened dark color like #1a1a1a or #111827 instead of pure black",
799
+ impact: 5
800
+ });
801
+ }
802
+ return findings;
803
+ }
804
+ };
805
+ function areNearDuplicate(a, b) {
806
+ const rgbA = extractRGB(a);
807
+ const rgbB = extractRGB(b);
808
+ if (!rgbA || !rgbB) return false;
809
+ const distance = Math.sqrt(
810
+ (rgbA.r - rgbB.r) ** 2 + (rgbA.g - rgbB.g) ** 2 + (rgbA.b - rgbB.b) ** 2
811
+ );
812
+ return distance > 0 && distance < 15;
813
+ }
814
+ function extractRGB(color) {
815
+ const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
816
+ if (match) {
817
+ return {
818
+ r: parseInt(match[1], 10),
819
+ g: parseInt(match[2], 10),
820
+ b: parseInt(match[3], 10)
821
+ };
822
+ }
823
+ return null;
824
+ }
825
+
826
+ // src/rules/media.ts
827
+ var imageDimensions = {
828
+ id: "image-dimensions",
829
+ name: "Image Dimension Attributes",
830
+ category: "consistency",
831
+ description: "Checks that images have explicit width/height attributes to prevent cumulative layout shift",
832
+ evaluate: (snapshot) => {
833
+ const findings = [];
834
+ const missing = [];
835
+ for (const el of snapshot.elements) {
836
+ if (el.tagName !== "img" || !el.isVisible) continue;
837
+ const hasWidth = el.attributes["width"] !== void 0;
838
+ const hasHeight = el.attributes["height"] !== void 0;
839
+ const hasCSSSize = el.computedStyles.width !== "auto" || el.computedStyles.height !== "auto";
840
+ if (!hasWidth && !hasHeight && !hasCSSSize) {
841
+ missing.push(el.selector);
842
+ }
843
+ }
844
+ if (missing.length > 0) {
845
+ findings.push({
846
+ ruleId: "image-dimensions",
847
+ severity: "warning",
848
+ category: "consistency",
849
+ selector: missing[0],
850
+ message: `${missing.length} image(s) without explicit dimensions \u2014 causes cumulative layout shift (CLS)`,
851
+ measured: `${missing.length} images without dimensions`,
852
+ expected: "width and height attributes on all images",
853
+ suggestion: "Add width and height attributes to <img> tags or set explicit CSS dimensions",
854
+ impact: Math.min(12, missing.length * 3)
855
+ });
856
+ }
857
+ return findings;
858
+ }
859
+ };
860
+ var imageContainment = {
861
+ id: "image-containment",
862
+ name: "Image Container Overflow",
863
+ category: "spacing",
864
+ description: "Checks that images don't overflow their parent containers",
865
+ evaluate: (snapshot) => {
866
+ const findings = [];
867
+ const overflowing = [];
868
+ for (const el of snapshot.elements) {
869
+ if (el.tagName !== "img" || !el.isVisible) continue;
870
+ if (!el.parentSelector) continue;
871
+ const parent = snapshot.elements.find((p) => p.selector === el.parentSelector);
872
+ if (!parent) continue;
873
+ if (el.box.width > parent.box.width + 2) {
874
+ overflowing.push(el.selector);
875
+ }
876
+ }
877
+ if (overflowing.length > 0) {
878
+ findings.push({
879
+ ruleId: "image-containment",
880
+ severity: "warning",
881
+ category: "spacing",
882
+ selector: overflowing[0],
883
+ message: `${overflowing.length} image(s) overflow their parent container`,
884
+ measured: `${overflowing.length} overflowing images`,
885
+ expected: "All images contained within parent bounds",
886
+ suggestion: "Add max-width: 100% and height: auto to images, or use object-fit: cover with fixed dimensions",
887
+ impact: Math.min(10, overflowing.length * 3)
888
+ });
889
+ }
890
+ return findings;
891
+ }
892
+ };
893
+ var imageAspectConsistency = {
894
+ id: "image-aspect-consistency",
895
+ name: "Image Aspect Ratio Consistency",
896
+ category: "consistency",
897
+ description: "Checks that sibling images share consistent aspect ratios",
898
+ evaluate: (snapshot) => {
899
+ const findings = [];
900
+ const parentMap = /* @__PURE__ */ new Map();
901
+ for (const el of snapshot.elements) {
902
+ if (el.tagName !== "img" || !el.isVisible || !el.parentSelector) continue;
903
+ const siblings = parentMap.get(el.parentSelector) ?? [];
904
+ siblings.push(el);
905
+ parentMap.set(el.parentSelector, siblings);
906
+ }
907
+ for (const [parentSelector, images] of parentMap) {
908
+ if (images.length < 3) continue;
909
+ const ratios = images.map((img) => {
910
+ if (img.box.height === 0) return 0;
911
+ return Math.round(img.box.width / img.box.height * 100) / 100;
912
+ }).filter((r) => r > 0);
913
+ if (ratios.length < 3) continue;
914
+ const uniqueRatios = new Set(ratios.map((r) => Math.round(r * 10)));
915
+ if (uniqueRatios.size > 2) {
916
+ findings.push({
917
+ ruleId: "image-aspect-consistency",
918
+ severity: "info",
919
+ category: "consistency",
920
+ selector: parentSelector,
921
+ message: `${uniqueRatios.size} different aspect ratios among ${images.length} sibling images \u2014 grid looks uneven`,
922
+ measured: `${uniqueRatios.size} aspect ratios`,
923
+ expected: "Consistent aspect ratios for sibling images",
924
+ suggestion: "Use object-fit: cover with fixed aspect-ratio on image containers for uniform grid appearance",
925
+ impact: Math.min(8, (uniqueRatios.size - 1) * 3)
926
+ });
927
+ }
928
+ }
929
+ return findings;
930
+ }
931
+ };
932
+ var iconButtonSize = {
933
+ id: "icon-button-size",
934
+ name: "Icon Button Proportions",
935
+ category: "hierarchy",
936
+ description: "Checks that icon-only buttons aren't excessively large or tiny",
937
+ evaluate: (snapshot) => {
938
+ const findings = [];
939
+ const oversized = [];
940
+ const undersized = [];
941
+ for (const el of snapshot.elements) {
942
+ if (!el.isVisible || !el.isInteractive) continue;
943
+ if (el.textContent.length > 3) continue;
944
+ if (el.tagName !== "button" && el.tagName !== "a") continue;
945
+ const size = Math.max(el.box.width, el.box.height);
946
+ if (size > 64) oversized.push(el.selector);
947
+ if (size > 0 && size < 24) undersized.push(el.selector);
948
+ }
949
+ if (oversized.length > 2) {
950
+ findings.push({
951
+ ruleId: "icon-button-size",
952
+ severity: "info",
953
+ category: "hierarchy",
954
+ selector: oversized[0],
955
+ message: `${oversized.length} icon button(s) larger than 64px \u2014 may overwhelm surrounding content`,
956
+ measured: `${oversized.length} oversized buttons`,
957
+ expected: "32\u201348px for icon-only buttons",
958
+ suggestion: "Size icon buttons to 36\u201344px for optimal visual weight and touch target balance",
959
+ impact: Math.min(5, oversized.length * 2)
960
+ });
961
+ }
962
+ return findings;
963
+ }
964
+ };
965
+ var colorOnlyInformation = {
966
+ id: "color-only-information",
967
+ name: "Color-Only Information",
968
+ category: "accessibility",
969
+ description: "Checks for potential reliance on color alone to convey meaning (WCAG 1.4.1)",
970
+ evaluate: (snapshot) => {
971
+ const findings = [];
972
+ const statusIndicators = snapshot.elements.filter((el) => {
973
+ if (!el.isVisible || el.textContent.length > 1) return false;
974
+ const bg = el.computedStyles.backgroundColor;
975
+ return el.box.width < 20 && el.box.height < 20 && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)";
976
+ });
977
+ if (statusIndicators.length > 3) {
978
+ findings.push({
979
+ ruleId: "color-only-information",
980
+ severity: "info",
981
+ category: "accessibility",
982
+ selector: statusIndicators[0].selector,
983
+ message: `${statusIndicators.length} small colored elements detected \u2014 ensure color isn't the only way to convey information`,
984
+ measured: `${statusIndicators.length} potential color-only indicators`,
985
+ expected: "Color paired with icons, text, or patterns",
986
+ suggestion: "Add icons, text labels, or patterns alongside color to ensure information is accessible to colorblind users",
987
+ impact: 5
988
+ });
989
+ }
990
+ return findings;
991
+ }
992
+ };
993
+ var linkDistinguishability = {
994
+ id: "link-distinguishability",
995
+ name: "Link Visual Distinction",
996
+ category: "color",
997
+ description: "Checks that links are visually distinguishable from surrounding text",
998
+ evaluate: (snapshot) => {
999
+ const findings = [];
1000
+ const indistinguishable = [];
1001
+ for (const el of snapshot.elements) {
1002
+ if (el.tagName !== "a" || !el.isVisible || !el.textContent) continue;
1003
+ if (!el.parentSelector) continue;
1004
+ const parent = snapshot.elements.find((p) => p.selector === el.parentSelector);
1005
+ if (!parent) continue;
1006
+ const sameColor = el.computedStyles.color === parent.computedStyles.color;
1007
+ const noUnderline = !el.computedStyles.textDecoration.includes("underline");
1008
+ const noBold = el.computedStyles.fontWeight === parent.computedStyles.fontWeight;
1009
+ if (sameColor && noUnderline && noBold) {
1010
+ indistinguishable.push(el.selector);
1011
+ }
1012
+ }
1013
+ if (indistinguishable.length > 2) {
1014
+ findings.push({
1015
+ ruleId: "link-distinguishability",
1016
+ severity: "warning",
1017
+ category: "color",
1018
+ selector: indistinguishable[0],
1019
+ message: `${indistinguishable.length} link(s) visually identical to surrounding text \u2014 users can't tell they're clickable`,
1020
+ measured: `${indistinguishable.length} indistinguishable links`,
1021
+ expected: "Links distinguished by color, underline, or weight",
1022
+ suggestion: "Make links visually distinct with color + underline on hover, or always-visible underline",
1023
+ impact: Math.min(12, indistinguishable.length * 3)
1024
+ });
1025
+ }
1026
+ return findings;
1027
+ }
1028
+ };
1029
+
1030
+ // src/rules/hierarchy.ts
1031
+ var headingSizeProgression = {
1032
+ id: "heading-size-progression",
1033
+ name: "Heading Size Progression",
1034
+ category: "hierarchy",
1035
+ description: "Checks that heading sizes decrease progressively (h1 > h2 > h3)",
1036
+ evaluate: (snapshot) => {
1037
+ const findings = [];
1038
+ const headingsByLevel = /* @__PURE__ */ new Map();
1039
+ for (const el of snapshot.elements) {
1040
+ if (!el.isVisible || !/^h[1-6]$/.test(el.tagName)) continue;
1041
+ const level = parseInt(el.tagName[1], 10);
1042
+ const size = parseFloat(el.computedStyles.fontSize);
1043
+ if (size > 0) {
1044
+ const sizes = headingsByLevel.get(level) ?? [];
1045
+ sizes.push(size);
1046
+ headingsByLevel.set(level, sizes);
1047
+ }
1048
+ }
1049
+ const levels = [...headingsByLevel.keys()].sort((a, b) => a - b);
1050
+ const violations = [];
1051
+ for (let i = 1; i < levels.length; i++) {
1052
+ const prevLevel = levels[i - 1];
1053
+ const currLevel = levels[i];
1054
+ const prevSizes = headingsByLevel.get(prevLevel);
1055
+ const currSizes = headingsByLevel.get(currLevel);
1056
+ const prevAvg = prevSizes.reduce((a, b) => a + b, 0) / prevSizes.length;
1057
+ const currAvg = currSizes.reduce((a, b) => a + b, 0) / currSizes.length;
1058
+ if (currAvg >= prevAvg) {
1059
+ violations.push(`h${currLevel} (${Math.round(currAvg)}px) >= h${prevLevel} (${Math.round(prevAvg)}px)`);
1060
+ }
1061
+ }
1062
+ if (violations.length > 0) {
1063
+ findings.push({
1064
+ ruleId: "heading-size-progression",
1065
+ severity: "error",
1066
+ category: "hierarchy",
1067
+ selector: "body",
1068
+ message: `Heading size hierarchy is broken: ${violations.join("; ")}`,
1069
+ measured: violations.join("; "),
1070
+ expected: "h1 > h2 > h3 > h4 in font size",
1071
+ suggestion: "Ensure each heading level is visually smaller than the level above it",
1072
+ impact: Math.min(20, violations.length * 8)
1073
+ });
1074
+ } else if (levels.length >= 2) {
1075
+ findings.push({
1076
+ ruleId: "heading-size-progression",
1077
+ severity: "pass",
1078
+ category: "hierarchy",
1079
+ selector: "body",
1080
+ message: "Heading sizes progress correctly through levels",
1081
+ measured: "Correct progression",
1082
+ expected: "h1 > h2 > h3",
1083
+ suggestion: "",
1084
+ impact: 0
1085
+ });
1086
+ }
1087
+ return findings;
1088
+ }
1089
+ };
1090
+ var ctaDominance = {
1091
+ id: "cta-dominance",
1092
+ name: "CTA Visual Dominance",
1093
+ category: "hierarchy",
1094
+ description: "Checks that primary CTAs are visually dominant on the page",
1095
+ evaluate: (snapshot) => {
1096
+ const findings = [];
1097
+ const buttons = snapshot.elements.filter(
1098
+ (el) => el.isVisible && el.isInteractive && (el.tagName === "button" || el.tagName === "a") && el.textContent.length > 0 && el.textContent.length < 30
1099
+ );
1100
+ if (buttons.length === 0) return findings;
1101
+ const areas = buttons.map((b) => b.box.width * b.box.height);
1102
+ if (areas.length >= 2) {
1103
+ const maxArea = Math.max(...areas);
1104
+ const minArea = Math.min(...areas);
1105
+ const ratio = maxArea / (minArea || 1);
1106
+ if (ratio < 1.2 && buttons.length >= 3) {
1107
+ findings.push({
1108
+ ruleId: "cta-dominance",
1109
+ severity: "info",
1110
+ category: "hierarchy",
1111
+ selector: buttons[0].selector,
1112
+ message: `${buttons.length} buttons with similar visual weight \u2014 no clear primary CTA`,
1113
+ measured: `Size ratio: ${ratio.toFixed(2)}`,
1114
+ expected: "Primary CTA at least 1.3x larger than secondary actions",
1115
+ suggestion: "Make the primary CTA visually distinct with larger size, stronger color, or bolder weight",
1116
+ impact: 8
1117
+ });
1118
+ }
1119
+ }
1120
+ return findings;
1121
+ }
1122
+ };
1123
+ var visualWeightDistribution = {
1124
+ id: "visual-weight-distribution",
1125
+ name: "Visual Weight Distribution",
1126
+ category: "hierarchy",
1127
+ description: "Checks that visual weight is distributed to create clear focal points",
1128
+ evaluate: (snapshot) => {
1129
+ const findings = [];
1130
+ const viewportHeight = snapshot.viewport.height;
1131
+ const aboveFold = snapshot.elements.filter(
1132
+ (el) => el.isVisible && el.box.y < viewportHeight
1133
+ );
1134
+ if (aboveFold.length === 0) return findings;
1135
+ const textElements = aboveFold.filter(
1136
+ (el) => el.textContent.length > 0
1137
+ );
1138
+ const maxFontSize = Math.max(
1139
+ ...textElements.map((el) => parseFloat(el.computedStyles.fontSize) || 0)
1140
+ );
1141
+ if (maxFontSize < 24 && textElements.length > 5) {
1142
+ findings.push({
1143
+ ruleId: "visual-weight-distribution",
1144
+ severity: "info",
1145
+ category: "hierarchy",
1146
+ selector: "body",
1147
+ message: `No text above 24px found above the fold \u2014 weak visual hierarchy`,
1148
+ measured: `Largest text: ${Math.round(maxFontSize)}px`,
1149
+ expected: "At least one prominent heading (24px+) above the fold",
1150
+ suggestion: "Add a larger heading or hero text to establish visual hierarchy above the fold",
1151
+ impact: 10
1152
+ });
1153
+ }
1154
+ return findings;
1155
+ }
1156
+ };
1157
+ var headingLevelStructure = {
1158
+ id: "heading-level-structure",
1159
+ name: "Heading Level Structure",
1160
+ category: "hierarchy",
1161
+ description: "Checks that heading levels are properly nested without skips",
1162
+ evaluate: (snapshot) => {
1163
+ const findings = [];
1164
+ const headings = snapshot.elements.filter((el) => /^h[1-6]$/.test(el.tagName) && el.isVisible).sort((a, b) => a.box.y - b.box.y);
1165
+ const skips = [];
1166
+ for (let i = 1; i < headings.length; i++) {
1167
+ const prev = parseInt(headings[i - 1].tagName[1], 10);
1168
+ const curr = parseInt(headings[i].tagName[1], 10);
1169
+ if (curr > prev + 1) {
1170
+ skips.push(`h${prev} \u2192 h${curr}`);
1171
+ }
1172
+ }
1173
+ if (skips.length > 0) {
1174
+ findings.push({
1175
+ ruleId: "heading-level-structure",
1176
+ severity: "warning",
1177
+ category: "hierarchy",
1178
+ selector: headings[0]?.selector ?? "body",
1179
+ message: `Heading levels skip: ${skips.join(", ")}`,
1180
+ measured: `${skips.length} level skip(s)`,
1181
+ expected: "Sequential heading levels (h1 \u2192 h2 \u2192 h3)",
1182
+ suggestion: "Use sequential heading levels. Don't skip from h2 to h4.",
1183
+ impact: Math.min(12, skips.length * 4)
1184
+ });
1185
+ }
1186
+ return findings;
1187
+ }
1188
+ };
1189
+
1190
+ // src/rules/interaction.ts
1191
+ var cursorAffordance = {
1192
+ id: "cursor-affordance",
1193
+ name: "Cursor Affordance",
1194
+ category: "consistency",
1195
+ description: "Checks that interactive elements show pointer cursor to signal clickability",
1196
+ evaluate: (snapshot) => {
1197
+ const findings = [];
1198
+ const missingPointer = [];
1199
+ for (const el of snapshot.elements) {
1200
+ if (!el.isVisible || !el.isInteractive) continue;
1201
+ if (el.tagName === "input" || el.tagName === "select" || el.tagName === "textarea") continue;
1202
+ if (el.computedStyles.cursor !== "pointer") {
1203
+ missingPointer.push(el.selector);
1204
+ }
1205
+ }
1206
+ if (missingPointer.length > 0) {
1207
+ findings.push({
1208
+ ruleId: "cursor-affordance",
1209
+ severity: "info",
1210
+ category: "consistency",
1211
+ selector: missingPointer[0],
1212
+ message: `${missingPointer.length} interactive element(s) without cursor:pointer \u2014 users can't tell they're clickable`,
1213
+ measured: `${missingPointer.length} missing cursor:pointer`,
1214
+ expected: "cursor:pointer on all clickable elements",
1215
+ suggestion: "Add cursor: pointer to all interactive elements (buttons, links, cards with click handlers)",
1216
+ impact: Math.min(8, missingPointer.length * 2)
1217
+ });
1218
+ }
1219
+ return findings;
1220
+ }
1221
+ };
1222
+ var transitionPresence = {
1223
+ id: "transition-presence",
1224
+ name: "Transition Presence",
1225
+ category: "consistency",
1226
+ description: "Checks that interactive elements have CSS transitions for hover/active states",
1227
+ evaluate: (snapshot) => {
1228
+ const findings = [];
1229
+ const noTransition = [];
1230
+ for (const el of snapshot.elements) {
1231
+ if (!el.isVisible || !el.isInteractive) continue;
1232
+ if (el.tagName === "input" || el.tagName === "select") continue;
1233
+ const transition = el.computedStyles.transition;
1234
+ if (!transition || transition === "none" || transition === "all 0s ease 0s") {
1235
+ noTransition.push(el.selector);
1236
+ }
1237
+ }
1238
+ if (noTransition.length > 5) {
1239
+ findings.push({
1240
+ ruleId: "transition-presence",
1241
+ severity: "info",
1242
+ category: "consistency",
1243
+ selector: noTransition[0],
1244
+ message: `${noTransition.length} interactive elements without CSS transitions \u2014 state changes feel abrupt`,
1245
+ measured: `${noTransition.length} without transitions`,
1246
+ expected: "Smooth transitions on interactive elements",
1247
+ suggestion: "Add transition: all 0.15s ease or transition: background-color 0.15s, transform 0.1s",
1248
+ impact: Math.min(6, Math.round(noTransition.length / 3))
1249
+ });
1250
+ }
1251
+ return findings;
1252
+ }
1253
+ };
1254
+ var transitionTiming = {
1255
+ id: "transition-timing",
1256
+ name: "Transition Timing",
1257
+ category: "consistency",
1258
+ description: "Checks that transition durations are in the optimal 100\u2013400ms range",
1259
+ evaluate: (snapshot) => {
1260
+ const findings = [];
1261
+ const tooSlow = [];
1262
+ for (const el of snapshot.elements) {
1263
+ if (!el.isVisible) continue;
1264
+ const transition = el.computedStyles.transition;
1265
+ if (!transition || transition === "none") continue;
1266
+ const durationMatch = transition.match(/([\d.]+)s/);
1267
+ if (durationMatch) {
1268
+ const duration = parseFloat(durationMatch[1]) * 1e3;
1269
+ if (duration > 500) {
1270
+ tooSlow.push(el.selector);
1271
+ }
1272
+ }
1273
+ }
1274
+ if (tooSlow.length > 2) {
1275
+ findings.push({
1276
+ ruleId: "transition-timing",
1277
+ severity: "info",
1278
+ category: "consistency",
1279
+ selector: tooSlow[0],
1280
+ message: `${tooSlow.length} element(s) with transitions > 500ms \u2014 feels sluggish`,
1281
+ measured: `${tooSlow.length} slow transitions`,
1282
+ expected: "100\u2013300ms for UI transitions, max 400ms for complex animations",
1283
+ suggestion: "Keep UI transitions under 300ms. Use 150ms for hover, 200ms for state changes, 300ms for layout shifts.",
1284
+ impact: Math.min(6, tooSlow.length * 2)
1285
+ });
1286
+ }
1287
+ return findings;
1288
+ }
1289
+ };
1290
+ var inputAffordance = {
1291
+ id: "input-affordance",
1292
+ name: "Input Visual Affordance",
1293
+ category: "hierarchy",
1294
+ description: "Checks that form inputs are visually distinguishable from surrounding content",
1295
+ evaluate: (snapshot) => {
1296
+ const findings = [];
1297
+ const invisibleInputs = [];
1298
+ for (const el of snapshot.elements) {
1299
+ if (!el.isVisible) continue;
1300
+ if (el.tagName !== "input" && el.tagName !== "textarea") continue;
1301
+ if (el.attributes["type"] === "hidden" || el.attributes["type"] === "submit") continue;
1302
+ const hasBorder = el.computedStyles.borderWidth !== "0px" && el.computedStyles.borderStyle !== "none";
1303
+ const hasBg = el.computedStyles.backgroundColor !== "transparent" && el.computedStyles.backgroundColor !== "rgba(0, 0, 0, 0)";
1304
+ const hasShadow = el.computedStyles.boxShadow !== "none" && el.computedStyles.boxShadow !== "";
1305
+ if (!hasBorder && !hasBg && !hasShadow) {
1306
+ invisibleInputs.push(el.selector);
1307
+ }
1308
+ }
1309
+ if (invisibleInputs.length > 0) {
1310
+ findings.push({
1311
+ ruleId: "input-affordance",
1312
+ severity: "warning",
1313
+ category: "hierarchy",
1314
+ selector: invisibleInputs[0],
1315
+ message: `${invisibleInputs.length} form input(s) with no visible border, background, or shadow \u2014 hard to identify as inputs`,
1316
+ measured: `${invisibleInputs.length} invisible inputs`,
1317
+ expected: "Visible border, background, or shadow on all form inputs",
1318
+ suggestion: "Add a visible border (1px solid) or background color to distinguish inputs from surrounding content",
1319
+ impact: Math.min(12, invisibleInputs.length * 4)
1320
+ });
1321
+ }
1322
+ return findings;
1323
+ }
1324
+ };
1325
+
1326
+ // src/rules/accessibility.ts
1327
+ var import_protocol3 = require("@argus-design/protocol");
1328
+ var imageAltText = {
1329
+ id: "image-alt-text",
1330
+ name: "Image Alt Text",
1331
+ category: "accessibility",
1332
+ description: "Checks that all images have alt attributes",
1333
+ evaluate: (snapshot) => {
1334
+ const findings = [];
1335
+ const missing = snapshot.elements.filter(
1336
+ (el) => el.tagName === "img" && el.isVisible && el.accessibility.hasAltText === false
1337
+ );
1338
+ if (missing.length > 0) {
1339
+ findings.push({
1340
+ ruleId: "image-alt-text",
1341
+ severity: "error",
1342
+ category: "accessibility",
1343
+ selector: missing[0].selector,
1344
+ message: `${missing.length} image(s) missing alt attribute`,
1345
+ measured: missing.length,
1346
+ expected: 0,
1347
+ suggestion: 'Add descriptive alt text to every image. Use alt="" for decorative images.',
1348
+ impact: Math.min(20, missing.length * 5)
1349
+ });
1350
+ } else {
1351
+ findings.push({
1352
+ ruleId: "image-alt-text",
1353
+ severity: "pass",
1354
+ category: "accessibility",
1355
+ selector: "body",
1356
+ message: "All images have alt attributes",
1357
+ measured: 0,
1358
+ expected: 0,
1359
+ suggestion: "",
1360
+ impact: 0
1361
+ });
1362
+ }
1363
+ return findings;
1364
+ }
1365
+ };
1366
+ var touchTargetSize = {
1367
+ id: "touch-target-size",
1368
+ name: "Touch Target Size",
1369
+ category: "accessibility",
1370
+ description: `Checks interactive elements meet minimum ${import_protocol3.TOUCH_TARGETS.minimum}px touch target size`,
1371
+ evaluate: (snapshot) => {
1372
+ const findings = [];
1373
+ const tooSmall = snapshot.elements.filter((el) => {
1374
+ if (!el.isInteractive || !el.isVisible) return false;
1375
+ return Math.min(el.box.width, el.box.height) < import_protocol3.TOUCH_TARGETS.minimum && Math.min(el.box.width, el.box.height) > 0;
1376
+ });
1377
+ if (tooSmall.length > 0) {
1378
+ findings.push({
1379
+ ruleId: "touch-target-size",
1380
+ severity: "warning",
1381
+ category: "accessibility",
1382
+ selector: tooSmall[0].selector,
1383
+ message: `${tooSmall.length} interactive element(s) below ${import_protocol3.TOUCH_TARGETS.minimum}px minimum`,
1384
+ measured: `${tooSmall.length} undersized targets`,
1385
+ expected: `All targets >= ${import_protocol3.TOUCH_TARGETS.minimum}px (recommended: ${import_protocol3.TOUCH_TARGETS.recommended}px)`,
1386
+ suggestion: `Increase clickable area to at least ${import_protocol3.TOUCH_TARGETS.recommended}x${import_protocol3.TOUCH_TARGETS.recommended}px`,
1387
+ impact: Math.min(15, tooSmall.length * 3)
1388
+ });
1389
+ }
1390
+ return findings;
1391
+ }
1392
+ };
1393
+ var landmarkPresence = {
1394
+ id: "landmark-presence",
1395
+ name: "Landmark Regions",
1396
+ category: "accessibility",
1397
+ description: "Checks for essential landmark regions (main, nav)",
1398
+ evaluate: (snapshot) => {
1399
+ const findings = [];
1400
+ const hasMain = snapshot.elements.some(
1401
+ (el) => el.tagName === "main" || el.accessibility.role === "main"
1402
+ );
1403
+ const hasNav = snapshot.elements.some(
1404
+ (el) => el.tagName === "nav" || el.accessibility.role === "navigation"
1405
+ );
1406
+ if (!hasMain) {
1407
+ findings.push({
1408
+ ruleId: "landmark-presence",
1409
+ severity: "warning",
1410
+ category: "accessibility",
1411
+ selector: "body",
1412
+ message: "Missing <main> landmark region",
1413
+ measured: "No main landmark",
1414
+ expected: "At least one <main> element",
1415
+ suggestion: "Wrap primary content in a <main> element",
1416
+ impact: 8
1417
+ });
1418
+ }
1419
+ if (!hasNav && snapshot.meta.linkCount > 3) {
1420
+ findings.push({
1421
+ ruleId: "landmark-presence",
1422
+ severity: "info",
1423
+ category: "accessibility",
1424
+ selector: "body",
1425
+ message: "Missing <nav> landmark region",
1426
+ measured: "No nav landmark",
1427
+ expected: "At least one <nav> element for navigation links",
1428
+ suggestion: "Wrap navigation links in a <nav> element",
1429
+ impact: 5
1430
+ });
1431
+ }
1432
+ return findings;
1433
+ }
1434
+ };
1435
+ var documentLanguage = {
1436
+ id: "document-language",
1437
+ name: "Document Language",
1438
+ category: "accessibility",
1439
+ description: "Checks that the document has a lang attribute",
1440
+ evaluate: (snapshot) => {
1441
+ const findings = [];
1442
+ if (!snapshot.lang) {
1443
+ findings.push({
1444
+ ruleId: "document-language",
1445
+ severity: "error",
1446
+ category: "accessibility",
1447
+ selector: "html",
1448
+ message: "Document missing lang attribute",
1449
+ measured: "No lang attribute",
1450
+ expected: 'lang attribute on <html> (e.g., lang="en")',
1451
+ suggestion: 'Add lang="en" (or appropriate language code) to the <html> element',
1452
+ impact: 10
1453
+ });
1454
+ }
1455
+ return findings;
1456
+ }
1457
+ };
1458
+
1459
+ // src/rules/semantic.ts
1460
+ var semanticInteractive = {
1461
+ id: "semantic-interactive",
1462
+ name: "Semantic Interactive Elements",
1463
+ category: "accessibility",
1464
+ description: "Checks that interactive elements use proper semantic tags (button, a) not div/span",
1465
+ evaluate: (snapshot) => {
1466
+ const findings = [];
1467
+ const divButtons = [];
1468
+ for (const el of snapshot.elements) {
1469
+ if (!el.isVisible) continue;
1470
+ const tag = el.tagName;
1471
+ if (tag !== "div" && tag !== "span") continue;
1472
+ const hasClickCursor = el.computedStyles.cursor === "pointer";
1473
+ const hasRole = el.accessibility.role === "button" || el.accessibility.role === "link";
1474
+ const hasTabIndex = el.accessibility.tabIndex === 0;
1475
+ if (hasClickCursor && !hasRole) {
1476
+ divButtons.push(el.selector);
1477
+ }
1478
+ }
1479
+ if (divButtons.length > 0) {
1480
+ findings.push({
1481
+ ruleId: "semantic-interactive",
1482
+ severity: "error",
1483
+ category: "accessibility",
1484
+ selector: divButtons[0],
1485
+ message: `${divButtons.length} element(s) with cursor:pointer but no semantic tag or role \u2014 likely clickable div/span`,
1486
+ measured: `${divButtons.length} non-semantic interactive elements`,
1487
+ expected: "Use <button> for actions, <a> for navigation",
1488
+ suggestion: "Replace clickable <div>/<span> with <button> (actions) or <a href> (links). This fixes keyboard accessibility automatically.",
1489
+ impact: Math.min(20, divButtons.length * 4)
1490
+ });
1491
+ }
1492
+ return findings;
1493
+ }
1494
+ };
1495
+ var semanticLists = {
1496
+ id: "semantic-lists",
1497
+ name: "Semantic List Structure",
1498
+ category: "accessibility",
1499
+ description: "Checks that repeated sibling groups use semantic list elements",
1500
+ evaluate: (snapshot) => {
1501
+ const findings = [];
1502
+ const parentMap = /* @__PURE__ */ new Map();
1503
+ for (const el of snapshot.elements) {
1504
+ if (!el.parentSelector || !el.isVisible) continue;
1505
+ const siblings = parentMap.get(el.parentSelector) ?? [];
1506
+ siblings.push(el);
1507
+ parentMap.set(el.parentSelector, siblings);
1508
+ }
1509
+ const divListParents = [];
1510
+ for (const [parentSelector, children] of parentMap) {
1511
+ if (children.length < 4) continue;
1512
+ const allDivs = children.every((c) => c.tagName === "div");
1513
+ const parent = snapshot.elements.find((e) => e.selector === parentSelector);
1514
+ if (!parent) continue;
1515
+ const isAlreadyList = parent.tagName === "ul" || parent.tagName === "ol" || parent.accessibility.role === "list";
1516
+ if (allDivs && !isAlreadyList) {
1517
+ const heights = children.map((c) => c.box.height);
1518
+ const avgHeight = heights.reduce((a, b) => a + b, 0) / heights.length;
1519
+ const deviation = heights.reduce((sum, h) => sum + Math.abs(h - avgHeight), 0) / heights.length;
1520
+ if (deviation < avgHeight * 0.3) {
1521
+ divListParents.push(parentSelector);
1522
+ }
1523
+ }
1524
+ }
1525
+ if (divListParents.length > 0) {
1526
+ findings.push({
1527
+ ruleId: "semantic-lists",
1528
+ severity: "info",
1529
+ category: "accessibility",
1530
+ selector: divListParents[0],
1531
+ message: `${divListParents.length} group(s) of repeated div siblings that may be better as <ul>/<ol> lists`,
1532
+ measured: `${divListParents.length} potential list groups`,
1533
+ expected: "Repeated items in <ul>/<ol> with <li> children",
1534
+ suggestion: "Wrap repeated item groups in <ul> with <li> children for screen reader navigation",
1535
+ impact: Math.min(8, divListParents.length * 2)
1536
+ });
1537
+ }
1538
+ return findings;
1539
+ }
1540
+ };
1541
+ var semanticTextElements = {
1542
+ id: "semantic-text-elements",
1543
+ name: "Semantic Text Elements",
1544
+ category: "accessibility",
1545
+ description: "Checks that paragraph-length text uses <p> tags, not bare <div>/<span>",
1546
+ evaluate: (snapshot) => {
1547
+ const findings = [];
1548
+ const longDivText = [];
1549
+ for (const el of snapshot.elements) {
1550
+ if (!el.isVisible) continue;
1551
+ if (el.tagName !== "div" && el.tagName !== "span") continue;
1552
+ if (el.textContent.length > 80 && el.childCount === 0) {
1553
+ longDivText.push(el.selector);
1554
+ }
1555
+ }
1556
+ if (longDivText.length > 3) {
1557
+ findings.push({
1558
+ ruleId: "semantic-text-elements",
1559
+ severity: "info",
1560
+ category: "accessibility",
1561
+ selector: longDivText[0],
1562
+ message: `${longDivText.length} <div>/<span> elements contain paragraph-length text \u2014 use <p> instead`,
1563
+ measured: `${longDivText.length} non-semantic text blocks`,
1564
+ expected: "Paragraph text in <p> elements",
1565
+ suggestion: "Use <p> for paragraph text. Screen readers use semantic tags for navigation.",
1566
+ impact: Math.min(6, longDivText.length)
1567
+ });
1568
+ }
1569
+ return findings;
1570
+ }
1571
+ };
1572
+ var emptyInteractiveElements = {
1573
+ id: "empty-interactive",
1574
+ name: "Empty Interactive Elements",
1575
+ category: "accessibility",
1576
+ description: "Checks that all buttons and links have accessible text content",
1577
+ evaluate: (snapshot) => {
1578
+ const findings = [];
1579
+ const empty = [];
1580
+ for (const el of snapshot.elements) {
1581
+ if (!el.isVisible || !el.isInteractive) continue;
1582
+ if (el.tagName !== "button" && el.tagName !== "a") continue;
1583
+ const hasText = el.textContent.trim().length > 0;
1584
+ const hasAriaLabel = el.accessibility.ariaLabel !== null;
1585
+ const hasTitle = el.attributes["title"] !== void 0;
1586
+ if (!hasText && !hasAriaLabel && !hasTitle) {
1587
+ empty.push(el.selector);
1588
+ }
1589
+ }
1590
+ if (empty.length > 0) {
1591
+ findings.push({
1592
+ ruleId: "empty-interactive",
1593
+ severity: "error",
1594
+ category: "accessibility",
1595
+ selector: empty[0],
1596
+ message: `${empty.length} button(s)/link(s) with no text, aria-label, or title \u2014 invisible to screen readers`,
1597
+ measured: `${empty.length} empty interactive elements`,
1598
+ expected: "All interactive elements have accessible text",
1599
+ suggestion: "Add text content, aria-label, or title to every button and link",
1600
+ impact: Math.min(20, empty.length * 5)
1601
+ });
1602
+ }
1603
+ return findings;
1604
+ }
1605
+ };
1606
+ var tableStructure = {
1607
+ id: "table-structure",
1608
+ name: "Table Structure",
1609
+ category: "accessibility",
1610
+ description: "Checks that data tables use <th> headers and have captions",
1611
+ evaluate: (snapshot) => {
1612
+ const findings = [];
1613
+ const tables = snapshot.elements.filter(
1614
+ (el) => el.tagName === "table" && el.isVisible
1615
+ );
1616
+ for (const table of tables) {
1617
+ const children = snapshot.elements.filter(
1618
+ (el) => el.parentSelector === table.selector
1619
+ );
1620
+ const hasHeaders = snapshot.elements.some(
1621
+ (el) => el.tagName === "th" && isDescendant(el, table.selector, snapshot.elements)
1622
+ );
1623
+ if (!hasHeaders) {
1624
+ findings.push({
1625
+ ruleId: "table-structure",
1626
+ severity: "warning",
1627
+ category: "accessibility",
1628
+ selector: table.selector,
1629
+ message: "Data table missing <th> header cells",
1630
+ measured: "No <th> found",
1631
+ expected: "Column/row headers using <th> with scope attribute",
1632
+ suggestion: "Add <th scope='col'> for column headers and <th scope='row'> for row headers",
1633
+ impact: 8
1634
+ });
1635
+ }
1636
+ }
1637
+ return findings;
1638
+ }
1639
+ };
1640
+ function isDescendant(el, ancestorSelector, allElements) {
1641
+ let current = el.parentSelector;
1642
+ let depth = 0;
1643
+ while (current && depth < 10) {
1644
+ if (current === ancestorSelector) return true;
1645
+ const parent = allElements.find((e) => e.selector === current);
1646
+ current = parent?.parentSelector ?? null;
1647
+ depth++;
1648
+ }
1649
+ return false;
1650
+ }
1651
+
1652
+ // src/rules/consistency.ts
1653
+ var borderRadiusConsistency = {
1654
+ id: "border-radius-consistency",
1655
+ name: "Border Radius Consistency",
1656
+ category: "consistency",
1657
+ description: "Checks that border-radius values follow a consistent scale",
1658
+ evaluate: (snapshot) => {
1659
+ const findings = [];
1660
+ const radii = /* @__PURE__ */ new Set();
1661
+ for (const el of snapshot.elements) {
1662
+ if (!el.isVisible) continue;
1663
+ const br = el.computedStyles.borderRadius;
1664
+ if (br && br !== "0px") {
1665
+ radii.add(br);
1666
+ }
1667
+ }
1668
+ if (radii.size > 5) {
1669
+ findings.push({
1670
+ ruleId: "border-radius-consistency",
1671
+ severity: "warning",
1672
+ category: "consistency",
1673
+ selector: "body",
1674
+ message: `${radii.size} distinct border-radius values found: ${[...radii].slice(0, 6).join(", ")}`,
1675
+ measured: radii.size,
1676
+ expected: "2\u20134 distinct values (small, medium, large, round)",
1677
+ suggestion: "Consolidate border-radius values to a scale: 4px (subtle), 8px (card), 12px (modal), 9999px (pill)",
1678
+ impact: Math.min(12, (radii.size - 4) * 3)
1679
+ });
1680
+ }
1681
+ return findings;
1682
+ }
1683
+ };
1684
+ var shadowConsistency = {
1685
+ id: "shadow-consistency",
1686
+ name: "Box Shadow Consistency",
1687
+ category: "consistency",
1688
+ description: "Checks that box-shadow values are reused consistently",
1689
+ evaluate: (snapshot) => {
1690
+ const findings = [];
1691
+ const shadows = /* @__PURE__ */ new Map();
1692
+ for (const el of snapshot.elements) {
1693
+ if (!el.isVisible) continue;
1694
+ const shadow = el.computedStyles.boxShadow;
1695
+ if (shadow && shadow !== "none") {
1696
+ shadows.set(shadow, (shadows.get(shadow) ?? 0) + 1);
1697
+ }
1698
+ }
1699
+ if (shadows.size > 4) {
1700
+ findings.push({
1701
+ ruleId: "shadow-consistency",
1702
+ severity: "info",
1703
+ category: "consistency",
1704
+ selector: "body",
1705
+ message: `${shadows.size} distinct box-shadow values \u2014 consider consolidating to an elevation scale`,
1706
+ measured: shadows.size,
1707
+ expected: "2\u20134 shadow levels (sm, md, lg, xl)",
1708
+ suggestion: "Define shadow tokens for consistent elevation: --shadow-sm, --shadow-md, --shadow-lg",
1709
+ impact: Math.min(8, (shadows.size - 3) * 2)
1710
+ });
1711
+ }
1712
+ return findings;
1713
+ }
1714
+ };
1715
+ var customPropertyUsage = {
1716
+ id: "custom-property-usage",
1717
+ name: "CSS Custom Property Usage",
1718
+ category: "consistency",
1719
+ description: "Checks whether the page uses CSS custom properties for design tokens",
1720
+ evaluate: (snapshot) => {
1721
+ const findings = [];
1722
+ if (!snapshot.meta.usesCustomProperties && snapshot.meta.visibleElements > 20) {
1723
+ findings.push({
1724
+ ruleId: "custom-property-usage",
1725
+ severity: "info",
1726
+ category: "consistency",
1727
+ selector: ":root",
1728
+ message: "No CSS custom properties detected \u2014 hardcoded values make consistency harder",
1729
+ measured: "0 custom properties",
1730
+ expected: "Design tokens defined as CSS custom properties",
1731
+ suggestion: "Define colors, spacing, and typography as CSS custom properties (--color-primary, --space-4, etc.)",
1732
+ impact: 8
1733
+ });
1734
+ }
1735
+ return findings;
1736
+ }
1737
+ };
1738
+ var interactiveStateConsistency = {
1739
+ id: "interactive-state-consistency",
1740
+ name: "Interactive Element Consistency",
1741
+ category: "consistency",
1742
+ description: "Checks that interactive elements (buttons, links) have consistent styling",
1743
+ evaluate: (snapshot) => {
1744
+ const findings = [];
1745
+ const buttons = snapshot.elements.filter(
1746
+ (el) => el.isVisible && el.tagName === "button" && el.textContent.length > 0
1747
+ );
1748
+ if (buttons.length >= 3) {
1749
+ const paddings = buttons.map(
1750
+ (b) => `${Math.round(b.padding.top)},${Math.round(b.padding.left)}`
1751
+ );
1752
+ const uniquePaddings = new Set(paddings);
1753
+ if (uniquePaddings.size > 3) {
1754
+ findings.push({
1755
+ ruleId: "interactive-state-consistency",
1756
+ severity: "warning",
1757
+ category: "consistency",
1758
+ selector: buttons[0].selector,
1759
+ message: `${uniquePaddings.size} distinct button padding patterns \u2014 buttons should share consistent sizing`,
1760
+ measured: `${uniquePaddings.size} padding patterns`,
1761
+ expected: "1\u20133 button size variants (small, medium, large)",
1762
+ suggestion: "Define button size variants with consistent padding values",
1763
+ impact: Math.min(10, (uniquePaddings.size - 2) * 3)
1764
+ });
1765
+ }
1766
+ const radii = new Set(buttons.map((b) => b.computedStyles.borderRadius));
1767
+ if (radii.size > 2) {
1768
+ findings.push({
1769
+ ruleId: "interactive-state-consistency",
1770
+ severity: "info",
1771
+ category: "consistency",
1772
+ selector: buttons[0].selector,
1773
+ message: `Buttons use ${radii.size} different border-radius values`,
1774
+ measured: `${radii.size} radius values`,
1775
+ expected: "1 consistent border-radius for buttons",
1776
+ suggestion: "Use the same border-radius across all button variants",
1777
+ impact: 5
1778
+ });
1779
+ }
1780
+ }
1781
+ return findings;
1782
+ }
1783
+ };
1784
+
1785
+ // src/rules/layout.ts
1786
+ var overflowDetection = {
1787
+ id: "overflow-detection",
1788
+ name: "Content Overflow Detection",
1789
+ category: "spacing",
1790
+ description: "Detects elements wider than viewport that cause horizontal scroll",
1791
+ evaluate: (snapshot) => {
1792
+ const findings = [];
1793
+ const overflowing = [];
1794
+ for (const el of snapshot.elements) {
1795
+ if (!el.isVisible) continue;
1796
+ if (el.box.x + el.box.width > snapshot.viewport.width + 2) {
1797
+ overflowing.push(el.selector);
1798
+ }
1799
+ }
1800
+ if (overflowing.length > 0) {
1801
+ findings.push({
1802
+ ruleId: "overflow-detection",
1803
+ severity: "error",
1804
+ category: "spacing",
1805
+ selector: overflowing[0],
1806
+ message: `${overflowing.length} element(s) overflow viewport width \u2014 causes horizontal scrollbar`,
1807
+ measured: `${overflowing.length} overflowing elements`,
1808
+ expected: "All elements within viewport bounds",
1809
+ suggestion: "Add overflow-x: hidden to container, or constrain element widths with max-width: 100%",
1810
+ impact: Math.min(20, overflowing.length * 5)
1811
+ });
1812
+ }
1813
+ return findings;
1814
+ }
1815
+ };
1816
+ var zIndexSanity = {
1817
+ id: "z-index-sanity",
1818
+ name: "Z-Index Value Sanity",
1819
+ category: "consistency",
1820
+ description: "Checks that z-index values are reasonable and follow a scale",
1821
+ evaluate: (snapshot) => {
1822
+ const findings = [];
1823
+ const zValues = [];
1824
+ for (const el of snapshot.elements) {
1825
+ if (!el.isVisible || !el.stackingContext) continue;
1826
+ const z = el.stackingContext.zIndex;
1827
+ if (z !== 0) {
1828
+ zValues.push({ selector: el.selector, z });
1829
+ }
1830
+ }
1831
+ if (zValues.length === 0) return findings;
1832
+ const max = Math.max(...zValues.map((v) => v.z));
1833
+ const distinct = new Set(zValues.map((v) => v.z)).size;
1834
+ const absurd = zValues.filter((v) => v.z > 1e3);
1835
+ if (absurd.length > 0) {
1836
+ findings.push({
1837
+ ruleId: "z-index-sanity",
1838
+ severity: "warning",
1839
+ category: "consistency",
1840
+ selector: absurd[0].selector,
1841
+ message: `${absurd.length} element(s) with z-index > 1000 (max: ${max}). Use a structured scale instead.`,
1842
+ measured: `Max z-index: ${max}`,
1843
+ expected: "Z-index values following a scale (1, 10, 20, 30, 40, 50, 100)",
1844
+ suggestion: "Define z-index tokens: --z-dropdown: 10, --z-sticky: 20, --z-modal: 30, --z-overlay: 40, --z-toast: 50",
1845
+ impact: Math.min(10, absurd.length * 3)
1846
+ });
1847
+ }
1848
+ if (distinct > 8) {
1849
+ findings.push({
1850
+ ruleId: "z-index-sanity",
1851
+ severity: "info",
1852
+ category: "consistency",
1853
+ selector: "body",
1854
+ message: `${distinct} distinct z-index values \u2014 consider a z-index scale`,
1855
+ measured: distinct,
1856
+ expected: "3\u20136 z-index levels",
1857
+ suggestion: "Consolidate z-index values to a defined scale",
1858
+ impact: Math.min(8, (distinct - 6) * 2)
1859
+ });
1860
+ }
1861
+ return findings;
1862
+ }
1863
+ };
1864
+ var flexAlignmentExplicit = {
1865
+ id: "flex-alignment-explicit",
1866
+ name: "Flex Alignment Explicit",
1867
+ category: "consistency",
1868
+ description: "Checks that flex containers explicitly set alignment rather than relying on defaults",
1869
+ evaluate: (snapshot) => {
1870
+ const findings = [];
1871
+ const implicitAlignment = [];
1872
+ for (const el of snapshot.elements) {
1873
+ if (!el.isVisible) continue;
1874
+ const display = el.computedStyles.display;
1875
+ if (!display.includes("flex")) continue;
1876
+ if (el.childCount < 2) continue;
1877
+ const ai = el.computedStyles.alignItems;
1878
+ const jc = el.computedStyles.justifyContent;
1879
+ if (ai === "normal" && jc === "normal") {
1880
+ implicitAlignment.push(el.selector);
1881
+ }
1882
+ }
1883
+ if (implicitAlignment.length > 5) {
1884
+ findings.push({
1885
+ ruleId: "flex-alignment-explicit",
1886
+ severity: "info",
1887
+ category: "consistency",
1888
+ selector: implicitAlignment[0],
1889
+ message: `${implicitAlignment.length} flex containers rely on default alignment \u2014 explicit is more maintainable`,
1890
+ measured: `${implicitAlignment.length} with default alignment`,
1891
+ expected: "Explicit align-items and justify-content on flex containers",
1892
+ suggestion: "Set align-items and justify-content explicitly on flex containers with 2+ children",
1893
+ impact: Math.min(6, implicitAlignment.length)
1894
+ });
1895
+ }
1896
+ return findings;
1897
+ }
1898
+ };
1899
+ var layoutNestingDepth = {
1900
+ id: "layout-nesting-depth",
1901
+ name: "Layout Nesting Depth",
1902
+ category: "consistency",
1903
+ description: "Checks that flex/grid nesting doesn't exceed reasonable depth",
1904
+ evaluate: (snapshot) => {
1905
+ const findings = [];
1906
+ const deeplyNested = [];
1907
+ const layoutElements = /* @__PURE__ */ new Set();
1908
+ for (const el of snapshot.elements) {
1909
+ if (!el.isVisible) continue;
1910
+ const d = el.computedStyles.display;
1911
+ if (d.includes("flex") || d.includes("grid")) {
1912
+ layoutElements.add(el.selector);
1913
+ }
1914
+ }
1915
+ for (const el of snapshot.elements) {
1916
+ if (!layoutElements.has(el.selector)) continue;
1917
+ let depth = 0;
1918
+ let current = el.parentSelector;
1919
+ while (current) {
1920
+ if (layoutElements.has(current)) depth++;
1921
+ const parent = snapshot.elements.find((e) => e.selector === current);
1922
+ current = parent?.parentSelector ?? null;
1923
+ if (depth > 6) break;
1924
+ }
1925
+ if (depth >= 4) {
1926
+ deeplyNested.push(el.selector);
1927
+ }
1928
+ }
1929
+ if (deeplyNested.length > 0) {
1930
+ findings.push({
1931
+ ruleId: "layout-nesting-depth",
1932
+ severity: "info",
1933
+ category: "consistency",
1934
+ selector: deeplyNested[0],
1935
+ message: `${deeplyNested.length} layout container(s) nested 4+ levels deep \u2014 consider simplifying`,
1936
+ measured: `${deeplyNested.length} deeply nested`,
1937
+ expected: "Max 3 levels of flex/grid nesting",
1938
+ suggestion: "Flatten layout structure where possible. Deep nesting often indicates over-engineering.",
1939
+ impact: Math.min(8, deeplyNested.length * 2)
1940
+ });
1941
+ }
1942
+ return findings;
1943
+ }
1944
+ };
1945
+ var positioningAudit = {
1946
+ id: "positioning-audit",
1947
+ name: "Positioning Audit",
1948
+ category: "spacing",
1949
+ description: "Audits fixed/absolute positioning for potential layout issues",
1950
+ evaluate: (snapshot) => {
1951
+ const findings = [];
1952
+ const fixed = [];
1953
+ const absolute = [];
1954
+ for (const el of snapshot.elements) {
1955
+ if (!el.isVisible) continue;
1956
+ if (el.computedStyles.position === "fixed") fixed.push(el.selector);
1957
+ if (el.computedStyles.position === "absolute") absolute.push(el.selector);
1958
+ }
1959
+ if (absolute.length > 10) {
1960
+ findings.push({
1961
+ ruleId: "positioning-audit",
1962
+ severity: "info",
1963
+ category: "spacing",
1964
+ selector: absolute[0],
1965
+ message: `${absolute.length} absolutely positioned elements \u2014 heavy use suggests layout could be simplified`,
1966
+ measured: `${absolute.length} absolute elements`,
1967
+ expected: "Minimal use of absolute positioning",
1968
+ suggestion: "Prefer flexbox/grid layout over absolute positioning. Reserve absolute for overlays and decorative elements.",
1969
+ impact: Math.min(8, Math.round(absolute.length / 3))
1970
+ });
1971
+ }
1972
+ if (fixed.length > 2) {
1973
+ findings.push({
1974
+ ruleId: "positioning-audit",
1975
+ severity: "info",
1976
+ category: "spacing",
1977
+ selector: fixed[0],
1978
+ message: `${fixed.length} fixed-position elements \u2014 check for overlap and content obscuring`,
1979
+ measured: `${fixed.length} fixed elements`,
1980
+ expected: "1\u20132 fixed elements (nav, FAB)",
1981
+ suggestion: "Limit fixed elements to essential UI like navigation and floating action buttons",
1982
+ impact: 4
1983
+ });
1984
+ }
1985
+ return findings;
1986
+ }
1987
+ };
1988
+
1989
+ // src/rules/index.ts
1990
+ var BUILT_IN_RULES = [
1991
+ // ── Spacing (9) ───────────────────────────────────────────────────────────
1992
+ spacingScale,
1993
+ spacingConsistency,
1994
+ spacingVariety,
1995
+ containerPadding,
1996
+ overflowDetection,
1997
+ positioningAudit,
1998
+ contentDensity,
1999
+ sectionBreathing,
2000
+ textEdgePadding,
2001
+ // ── Typography (6) ────────────────────────────────────────────────────────
2002
+ typeScale,
2003
+ fontSizeVariety,
2004
+ fontWeightUsage,
2005
+ lineHeightReadability,
2006
+ fontFamilyCount,
2007
+ lineLength,
2008
+ // ── Color (5) ─────────────────────────────────────────────────────────────
2009
+ colorPaletteSize,
2010
+ contrastCompliance,
2011
+ colorConsistency,
2012
+ pureBlackAvoidance,
2013
+ linkDistinguishability,
2014
+ // ── Hierarchy (6) ─────────────────────────────────────────────────────────
2015
+ headingSizeProgression,
2016
+ ctaDominance,
2017
+ visualWeightDistribution,
2018
+ headingLevelStructure,
2019
+ inputAffordance,
2020
+ iconButtonSize,
2021
+ // ── Accessibility (10) ────────────────────────────────────────────────────
2022
+ imageAltText,
2023
+ touchTargetSize,
2024
+ landmarkPresence,
2025
+ documentLanguage,
2026
+ semanticInteractive,
2027
+ semanticLists,
2028
+ semanticTextElements,
2029
+ emptyInteractiveElements,
2030
+ tableStructure,
2031
+ colorOnlyInformation,
2032
+ // ── Consistency (12) ──────────────────────────────────────────────────────
2033
+ borderRadiusConsistency,
2034
+ shadowConsistency,
2035
+ customPropertyUsage,
2036
+ interactiveStateConsistency,
2037
+ zIndexSanity,
2038
+ flexAlignmentExplicit,
2039
+ layoutNestingDepth,
2040
+ cursorAffordance,
2041
+ transitionPresence,
2042
+ transitionTiming,
2043
+ imageDimensions,
2044
+ imageAspectConsistency,
2045
+ imageContainment
2046
+ ];
2047
+ var RULE_COUNT = BUILT_IN_RULES.length;
2048
+ function getRulesByCategory(category) {
2049
+ return BUILT_IN_RULES.filter((r) => r.category === category);
2050
+ }
2051
+ function getRuleById(id) {
2052
+ return BUILT_IN_RULES.find((r) => r.id === id);
2053
+ }
2054
+ function getAllRuleIds() {
2055
+ return BUILT_IN_RULES.map((r) => r.id);
2056
+ }
2057
+ function getRuleCounts() {
2058
+ const counts = {};
2059
+ for (const rule of BUILT_IN_RULES) {
2060
+ counts[rule.category] = (counts[rule.category] ?? 0) + 1;
2061
+ }
2062
+ return counts;
2063
+ }
2064
+
2065
+ // src/core/engine.ts
2066
+ function score(snapshot, options = {}) {
2067
+ const {
2068
+ categories = import_protocol4.RULE_CATEGORIES,
2069
+ customRules = [],
2070
+ _threshold = import_protocol4.DEFAULT_THRESHOLD,
2071
+ skipRules = []
2072
+ } = options;
2073
+ const startTime = performance.now();
2074
+ const skipSet = new Set(skipRules);
2075
+ const allRules = [...BUILT_IN_RULES, ...customRules].filter(
2076
+ (rule) => categories.includes(rule.category) && !skipSet.has(rule.id)
2077
+ );
2078
+ const allFindings = [];
2079
+ const ruleCount = allRules.length;
2080
+ for (const rule of allRules) {
2081
+ try {
2082
+ const findings = rule.evaluate(snapshot);
2083
+ allFindings.push(...findings);
2084
+ } catch (error) {
2085
+ allFindings.push({
2086
+ ruleId: rule.id,
2087
+ severity: "info",
2088
+ category: rule.category,
2089
+ selector: "body",
2090
+ message: `Rule ${rule.id} failed to evaluate: ${error instanceof Error ? error.message : "unknown error"}`,
2091
+ measured: "error",
2092
+ expected: "successful evaluation",
2093
+ suggestion: "This rule encountered an error and was skipped",
2094
+ impact: 0
2095
+ });
2096
+ }
2097
+ }
2098
+ const categoryScores = [];
2099
+ for (const category of categories) {
2100
+ const categoryFindings = allFindings.filter((f) => f.category === category);
2101
+ const categoryRules = allRules.filter((r) => r.category === category);
2102
+ const totalImpact = categoryFindings.reduce((sum, f) => sum + f.impact, 0);
2103
+ const maxPossible = 100;
2104
+ const categoryScore = Math.max(0, Math.round(maxPossible - totalImpact));
2105
+ categoryScores.push({
2106
+ category,
2107
+ score: categoryScore,
2108
+ maxScore: maxPossible,
2109
+ findings: categoryFindings,
2110
+ ruleCount: categoryRules.length,
2111
+ passCount: categoryFindings.filter((f) => f.severity === "pass").length,
2112
+ warningCount: categoryFindings.filter((f) => f.severity === "warning").length,
2113
+ errorCount: categoryFindings.filter((f) => f.severity === "error").length
2114
+ });
2115
+ }
2116
+ const totalScore = categoryScores.length > 0 ? Math.round(
2117
+ categoryScores.reduce((sum, c) => sum + c.score, 0) / categoryScores.length
2118
+ ) : 100;
2119
+ const sortedFindings = [...allFindings].filter((f) => f.severity !== "pass").sort((a, b) => b.impact - a.impact);
2120
+ const durationMs = Math.round(performance.now() - startTime);
2121
+ return {
2122
+ totalScore,
2123
+ categories: categoryScores,
2124
+ findings: sortedFindings,
2125
+ elementsAnalyzed: snapshot.elements.length,
2126
+ rulesEvaluated: ruleCount,
2127
+ timestamp: Date.now(),
2128
+ durationMs,
2129
+ source: snapshot.url
2130
+ };
2131
+ }
2132
+ function suggest(report, maxSuggestions = 10, focus) {
2133
+ let findings = report.findings.filter(
2134
+ (f) => f.severity === "error" || f.severity === "warning"
2135
+ );
2136
+ if (focus) {
2137
+ findings = findings.filter((f) => f.category === focus);
2138
+ }
2139
+ return findings.slice(0, maxSuggestions).map((finding, index) => ({
2140
+ findingId: `${finding.ruleId}-${index}`,
2141
+ selector: finding.selector,
2142
+ changes: parseSuggestionChanges(finding),
2143
+ expectedImprovement: finding.impact,
2144
+ priority: index + 1,
2145
+ rationale: finding.message
2146
+ }));
2147
+ }
2148
+ function compare(before, after) {
2149
+ const categoryDeltas = before.categories.map((bc) => {
2150
+ const ac = after.categories.find((c) => c.category === bc.category);
2151
+ return {
2152
+ category: bc.category,
2153
+ delta: ac ? ac.score - bc.score : 0
2154
+ };
2155
+ });
2156
+ const beforeRuleIds = new Set(before.findings.map((f) => f.ruleId));
2157
+ const regressions = after.findings.filter(
2158
+ (f) => !beforeRuleIds.has(f.ruleId) && f.severity !== "pass"
2159
+ );
2160
+ const afterRuleIds = new Set(after.findings.map((f) => f.ruleId));
2161
+ const fixes = before.findings.filter(
2162
+ (f) => !afterRuleIds.has(f.ruleId) && f.severity !== "pass"
2163
+ );
2164
+ return {
2165
+ before: {
2166
+ url: before.source,
2167
+ totalScore: before.totalScore,
2168
+ timestamp: before.timestamp
2169
+ },
2170
+ after: {
2171
+ url: after.source,
2172
+ totalScore: after.totalScore,
2173
+ timestamp: after.timestamp
2174
+ },
2175
+ scoreDelta: after.totalScore - before.totalScore,
2176
+ categoryDeltas,
2177
+ structuralChanges: [],
2178
+ // Requires PageSnapshot comparison (done at inspector level)
2179
+ regressions,
2180
+ fixes
2181
+ };
2182
+ }
2183
+ function audit(snapshot, options = {}) {
2184
+ const scoreReport = score(snapshot, options);
2185
+ const accessibilityViolations = scoreReport.findings.filter((f) => f.category === "accessibility" && f.severity !== "pass").map((f) => ({
2186
+ criterion: mapRuleToWCAG(f.ruleId),
2187
+ level: "AA",
2188
+ description: f.message,
2189
+ selectors: [f.selector],
2190
+ fix: f.suggestion
2191
+ }));
2192
+ const prioritizedFixes = suggest(scoreReport, 20);
2193
+ const summary = {
2194
+ totalScore: scoreReport.totalScore,
2195
+ grade: (0, import_protocol4.scoreToGrade)(scoreReport.totalScore),
2196
+ topIssue: scoreReport.findings.length > 0 ? scoreReport.findings[0].message : "No issues found",
2197
+ quickWins: prioritizedFixes.filter((s) => s.expectedImprovement >= 5).length,
2198
+ criticalIssues: scoreReport.findings.filter((f) => f.severity === "error").length
2199
+ };
2200
+ return {
2201
+ scoreReport,
2202
+ accessibilityViolations,
2203
+ prioritizedFixes,
2204
+ summary
2205
+ };
2206
+ }
2207
+ function formatReport(report) {
2208
+ const lines = [];
2209
+ const grade = (0, import_protocol4.scoreToGrade)(report.totalScore);
2210
+ lines.push(`# Design Score: ${report.totalScore}/100 (${grade})`);
2211
+ lines.push("");
2212
+ lines.push(`Analyzed ${report.elementsAnalyzed} elements with ${report.rulesEvaluated} rules in ${report.durationMs}ms.`);
2213
+ lines.push("");
2214
+ for (const cat of report.categories) {
2215
+ const bar = scoreBar(cat.score);
2216
+ lines.push(`## ${cat.category.charAt(0).toUpperCase() + cat.category.slice(1)}: ${cat.score}/100 ${bar}`);
2217
+ const issues = cat.findings.filter((f) => f.severity !== "pass");
2218
+ if (issues.length === 0) {
2219
+ lines.push("All checks passed.");
2220
+ } else {
2221
+ for (const f of issues) {
2222
+ const icon = f.severity === "error" ? "\u{1F534}" : f.severity === "warning" ? "\u{1F7E1}" : "\u{1F535}";
2223
+ lines.push(`${icon} **${f.ruleId}**: ${f.message}`);
2224
+ if (f.suggestion) lines.push(` \u2192 ${f.suggestion}`);
2225
+ }
2226
+ }
2227
+ lines.push("");
2228
+ }
2229
+ return lines.join("\n");
2230
+ }
2231
+ function parseSuggestionChanges(_finding) {
2232
+ return [];
2233
+ }
2234
+ function mapRuleToWCAG(ruleId) {
2235
+ const map = {
2236
+ "contrast-compliance": "1.4.3",
2237
+ "image-alt-text": "1.1.1",
2238
+ "touch-target-size": "2.5.8",
2239
+ "heading-level-structure": "1.3.1",
2240
+ "landmark-presence": "1.3.6",
2241
+ "document-language": "3.1.1"
2242
+ };
2243
+ return map[ruleId] ?? "4.1.2";
2244
+ }
2245
+ function scoreBar(score2) {
2246
+ const filled = Math.round(score2 / 10);
2247
+ return "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
2248
+ }
2249
+
2250
+ // src/core/reporter.ts
2251
+ var import_protocol5 = require("@argus-design/protocol");
2252
+ function generateHTMLReport(report) {
2253
+ const grade = (0, import_protocol5.scoreToGrade)(report.totalScore);
2254
+ const gradeColor = getGradeColor(report.totalScore);
2255
+ const date = new Date(report.timestamp).toLocaleDateString("en-US", {
2256
+ year: "numeric",
2257
+ month: "long",
2258
+ day: "numeric",
2259
+ hour: "2-digit",
2260
+ minute: "2-digit"
2261
+ });
2262
+ return `<!DOCTYPE html>
2263
+ <html lang="en">
2264
+ <head>
2265
+ <meta charset="UTF-8">
2266
+ <meta name="viewport" content="width=device-width,initial-scale=1">
2267
+ <title>Argus Design Report \u2014 ${report.source}</title>
2268
+ <style>
2269
+ *{margin:0;padding:0;box-sizing:border-box}
2270
+ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0a;color:#e5e5e5;font-size:14px;line-height:1.6;padding:40px 20px}
2271
+ .container{max-width:800px;margin:0 auto}
2272
+ .header{text-align:center;margin-bottom:48px}
2273
+ .header h1{font-size:14px;color:#737373;letter-spacing:0.08em;text-transform:uppercase;margin-bottom:8px;font-weight:500}
2274
+ .header .url{font-size:16px;color:#a3a3a3;margin-bottom:24px}
2275
+ .score-ring{width:140px;height:140px;margin:0 auto 16px;position:relative}
2276
+ .score-ring svg{transform:rotate(-90deg)}
2277
+ .score-ring .score-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:42px;font-weight:700;letter-spacing:-0.03em}
2278
+ .score-ring .grade{position:absolute;bottom:28px;left:50%;transform:translateX(-50%);font-size:13px;padding:2px 10px;border-radius:4px;font-weight:600}
2279
+ .date{font-size:12px;color:#525252;margin-top:8px}
2280
+ .categories{display:grid;gap:16px;margin-bottom:40px}
2281
+ .cat-card{background:#111;border:1px solid #262626;border-radius:8px;padding:20px}
2282
+ .cat-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
2283
+ .cat-name{font-size:14px;font-weight:600;text-transform:capitalize}
2284
+ .cat-score{font-family:monospace;font-size:20px;font-weight:700}
2285
+ .bar-track{height:6px;background:#1a1a1a;border-radius:3px;overflow:hidden;margin-bottom:16px}
2286
+ .bar-fill{height:100%;border-radius:3px;transition:width 1s ease}
2287
+ .findings{list-style:none}
2288
+ .finding{display:flex;gap:10px;padding:6px 0;font-size:13px;color:#a3a3a3;border-bottom:1px solid #1a1a1a}
2289
+ .finding:last-child{border-bottom:none}
2290
+ .dot{width:7px;height:7px;border-radius:50%;margin-top:6px;flex-shrink:0}
2291
+ .dot-error{background:#f87171}
2292
+ .dot-warning{background:#fbbf24}
2293
+ .dot-info{background:#60a5fa}
2294
+ .dot-pass{background:#4ade80}
2295
+ .finding strong{color:#e5e5e5;font-weight:500}
2296
+ .finding .suggestion{color:#525252;font-style:italic}
2297
+ .meta{text-align:center;color:#525252;font-size:12px;margin-top:40px;padding-top:20px;border-top:1px solid #1a1a1a}
2298
+ .stats{display:flex;justify-content:center;gap:32px;margin-bottom:40px}
2299
+ .stat{text-align:center}
2300
+ .stat-val{font-size:22px;font-weight:700;font-family:monospace;color:#22d3ee}
2301
+ .stat-label{font-size:11px;color:#525252;text-transform:uppercase;letter-spacing:0.05em}
2302
+ </style>
2303
+ </head>
2304
+ <body>
2305
+ <div class="container">
2306
+ <div class="header">
2307
+ <h1>argus design report</h1>
2308
+ <div class="url">${escapeHTML(report.source)}</div>
2309
+ <div class="score-ring">
2310
+ <svg width="140" height="140" viewBox="0 0 140 140">
2311
+ <circle cx="70" cy="70" r="60" fill="none" stroke="#1a1a1a" stroke-width="8"/>
2312
+ <circle cx="70" cy="70" r="60" fill="none" stroke="${gradeColor}" stroke-width="8"
2313
+ stroke-dasharray="${report.totalScore / 100 * 377} 377"
2314
+ stroke-linecap="round"/>
2315
+ </svg>
2316
+ <span class="score-text">${report.totalScore}</span>
2317
+ <span class="grade" style="background:${gradeColor}20;color:${gradeColor};border:1px solid ${gradeColor}40">${grade}</span>
2318
+ </div>
2319
+ <div class="date">${date} \xB7 ${report.durationMs}ms \xB7 ${report.elementsAnalyzed} elements \xB7 ${report.rulesEvaluated} rules</div>
2320
+ </div>
2321
+
2322
+ <div class="stats">
2323
+ <div class="stat"><div class="stat-val">${report.findings.filter((f) => f.severity === "error").length}</div><div class="stat-label">errors</div></div>
2324
+ <div class="stat"><div class="stat-val">${report.findings.filter((f) => f.severity === "warning").length}</div><div class="stat-label">warnings</div></div>
2325
+ <div class="stat"><div class="stat-val">${report.findings.filter((f) => f.severity === "info").length}</div><div class="stat-label">info</div></div>
2326
+ <div class="stat"><div class="stat-val">${report.categories.reduce((sum, c) => sum + c.passCount, 0)}</div><div class="stat-label">passed</div></div>
2327
+ </div>
2328
+
2329
+ <div class="categories">
2330
+ ${report.categories.map((cat) => renderCategory(cat)).join("\n")}
2331
+ </div>
2332
+
2333
+ <div class="meta">
2334
+ Generated by Argus Design Intelligence Runtime \xB7 <a href="https://argus.design" style="color:#22d3ee">argus.design</a>
2335
+ </div>
2336
+ </div>
2337
+ </body>
2338
+ </html>`;
2339
+ }
2340
+ function generateAuditHTMLReport(audit2) {
2341
+ let html = generateHTMLReport(audit2.scoreReport);
2342
+ if (audit2.accessibilityViolations.length > 0) {
2343
+ const a11ySection = `
2344
+ <div class="cat-card" style="border-color:#f8717140;margin-top:16px">
2345
+ <div class="cat-header">
2346
+ <span class="cat-name">WCAG Violations</span>
2347
+ <span class="cat-score" style="color:#f87171">${audit2.accessibilityViolations.length}</span>
2348
+ </div>
2349
+ <ul class="findings">
2350
+ ${audit2.accessibilityViolations.map((v) => `
2351
+ <li class="finding">
2352
+ <span class="dot dot-error"></span>
2353
+ <div><strong>[${v.criterion}] ${escapeHTML(v.description)}</strong><br><span class="suggestion">Fix: ${escapeHTML(v.fix)}</span></div>
2354
+ </li>`).join("")}
2355
+ </ul>
2356
+ </div>`;
2357
+ html = html.replace('</div>\n\n <div class="meta">', `${a11ySection}
2358
+ </div>
2359
+
2360
+ <div class="meta">`);
2361
+ }
2362
+ return html;
2363
+ }
2364
+ function renderCategory(cat) {
2365
+ const color = getGradeColor(cat.score);
2366
+ const issues = cat.findings.filter((f) => f.severity !== "pass");
2367
+ const passes = cat.findings.filter((f) => f.severity === "pass");
2368
+ return ` <div class="cat-card">
2369
+ <div class="cat-header">
2370
+ <span class="cat-name">${cat.category}</span>
2371
+ <span class="cat-score" style="color:${color}">${cat.score}</span>
2372
+ </div>
2373
+ <div class="bar-track"><div class="bar-fill" style="width:${cat.score}%;background:${color}"></div></div>
2374
+ <ul class="findings">
2375
+ ${issues.map((f) => renderFinding(f)).join("\n")}
2376
+ ${passes.length > 0 && issues.length > 0 ? ` <li class="finding"><span class="dot dot-pass"></span><div>${passes.length} check(s) passed</div></li>` : ""}
2377
+ ${issues.length === 0 ? ` <li class="finding"><span class="dot dot-pass"></span><div>All ${cat.ruleCount} checks passed</div></li>` : ""}
2378
+ </ul>
2379
+ </div>`;
2380
+ }
2381
+ function renderFinding(f) {
2382
+ const dotClass = f.severity === "error" ? "dot-error" : f.severity === "warning" ? "dot-warning" : "dot-info";
2383
+ return ` <li class="finding">
2384
+ <span class="dot ${dotClass}"></span>
2385
+ <div><strong>${f.ruleId}</strong>: ${escapeHTML(f.message)}${f.suggestion ? `<br><span class="suggestion">\u2192 ${escapeHTML(f.suggestion)}</span>` : ""}</div>
2386
+ </li>`;
2387
+ }
2388
+ function getGradeColor(score2) {
2389
+ if (score2 >= 90) return "#4ade80";
2390
+ if (score2 >= 75) return "#22d3ee";
2391
+ if (score2 >= 55) return "#fbbf24";
2392
+ return "#f87171";
2393
+ }
2394
+ function escapeHTML(str) {
2395
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2396
+ }
2397
+ // Annotate the CommonJS export names for ESM import in node:
2398
+ 0 && (module.exports = {
2399
+ BUILT_IN_RULES,
2400
+ RULE_COUNT,
2401
+ audit,
2402
+ borderRadiusConsistency,
2403
+ colorConsistency,
2404
+ colorOnlyInformation,
2405
+ colorPaletteSize,
2406
+ compare,
2407
+ containerPadding,
2408
+ contentDensity,
2409
+ contrastCompliance,
2410
+ ctaDominance,
2411
+ cursorAffordance,
2412
+ customPropertyUsage,
2413
+ documentLanguage,
2414
+ emptyInteractiveElements,
2415
+ flexAlignmentExplicit,
2416
+ fontFamilyCount,
2417
+ fontSizeVariety,
2418
+ fontWeightUsage,
2419
+ formatReport,
2420
+ generateAuditHTMLReport,
2421
+ generateHTMLReport,
2422
+ getAllRuleIds,
2423
+ getRuleById,
2424
+ getRuleCounts,
2425
+ getRulesByCategory,
2426
+ headingLevelStructure,
2427
+ headingSizeProgression,
2428
+ iconButtonSize,
2429
+ imageAltText,
2430
+ imageAspectConsistency,
2431
+ imageContainment,
2432
+ imageDimensions,
2433
+ inputAffordance,
2434
+ interactiveStateConsistency,
2435
+ landmarkPresence,
2436
+ layoutNestingDepth,
2437
+ lineHeightReadability,
2438
+ lineLength,
2439
+ linkDistinguishability,
2440
+ overflowDetection,
2441
+ positioningAudit,
2442
+ pureBlackAvoidance,
2443
+ score,
2444
+ sectionBreathing,
2445
+ semanticInteractive,
2446
+ semanticLists,
2447
+ semanticTextElements,
2448
+ shadowConsistency,
2449
+ spacingConsistency,
2450
+ spacingScale,
2451
+ spacingVariety,
2452
+ suggest,
2453
+ tableStructure,
2454
+ textEdgePadding,
2455
+ touchTargetSize,
2456
+ transitionPresence,
2457
+ transitionTiming,
2458
+ typeScale,
2459
+ visualWeightDistribution,
2460
+ zIndexSanity
2461
+ });