tether-ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +83 -2
  2. package/dist/approvals/match.d.ts +14 -0
  3. package/dist/approvals/match.js +216 -0
  4. package/dist/approvals/storage.d.ts +4 -0
  5. package/dist/approvals/storage.js +44 -0
  6. package/dist/cli/approvals.d.ts +2 -0
  7. package/dist/cli/approvals.js +113 -0
  8. package/dist/cli/format.d.ts +2 -2
  9. package/dist/cli/format.js +24 -5
  10. package/dist/cli/index.js +25 -21
  11. package/dist/cli/json.d.ts +60 -2
  12. package/dist/cli/json.js +66 -41
  13. package/dist/drift/compare.d.ts +4 -1
  14. package/dist/drift/compare.js +76 -30
  15. package/dist/drift/evidence.js +1 -0
  16. package/dist/scanner/collect.d.ts +2 -1
  17. package/dist/scanner/collect.js +16 -4
  18. package/dist/scanner/scan.js +127 -62
  19. package/dist/types.d.ts +59 -1
  20. package/dist/utils/config.js +15 -2
  21. package/dist/utils/coverage.d.ts +3 -0
  22. package/dist/utils/coverage.js +28 -0
  23. package/dist/utils/scenarios.d.ts +24 -0
  24. package/dist/utils/scenarios.js +82 -0
  25. package/dist/utils/storage.js +8 -3
  26. package/package.json +1 -1
  27. package/report/out/404/index.html +1 -1
  28. package/report/out/404.html +1 -1
  29. package/report/out/__next.__PAGE__.txt +3 -3
  30. package/report/out/__next._full.txt +3 -3
  31. package/report/out/__next._tree.txt +2 -2
  32. package/report/out/_next/static/chunks/330-b092d894bdb2103c.js +1 -0
  33. package/report/out/_next/static/chunks/app/_not-found/{page-032313fc4524b472.js → page-172b410568146bc8.js} +1 -1
  34. package/report/out/_next/static/chunks/app/contract/{page-60f6d610ece63f59.js → page-f49364851a596fef.js} +1 -1
  35. package/report/out/_next/static/chunks/app/drift/{page-655683d6032a79a8.js → page-e0f2eda1e981d86f.js} +1 -1
  36. package/report/out/_next/static/chunks/app/layout-9a410c21fc0ba3c9.js +1 -0
  37. package/report/out/_next/static/chunks/app/{page-bdfcce47bd844e7e.js → page-e399a35da3e55f00.js} +1 -1
  38. package/report/out/_next/static/chunks/app/viewports/{page-6e44561586c30871.js → page-5a01227414ac4842.js} +1 -1
  39. package/report/out/_next/static/chunks/{main-app-e3082ce30f616b80.js → main-app-e7e077df7256067f.js} +1 -1
  40. package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-94732500710e6d55.js +1 -0
  41. package/report/out/_next/static/css/b754cdc60c7b43bb.css +1 -0
  42. package/report/out/_not-found/__next._full.txt +2 -2
  43. package/report/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  44. package/report/out/_not-found/__next._tree.txt +2 -2
  45. package/report/out/_not-found/index.html +1 -1
  46. package/report/out/_not-found/index.txt +2 -2
  47. package/report/out/contract/__next._full.txt +3 -3
  48. package/report/out/contract/__next._tree.txt +2 -2
  49. package/report/out/contract/__next.contract.__PAGE__.txt +3 -3
  50. package/report/out/contract/index.html +1 -1
  51. package/report/out/contract/index.txt +3 -3
  52. package/report/out/drift/__next._full.txt +3 -3
  53. package/report/out/drift/__next._tree.txt +2 -2
  54. package/report/out/drift/__next.drift.__PAGE__.txt +3 -3
  55. package/report/out/drift/index.html +1 -1
  56. package/report/out/drift/index.txt +3 -3
  57. package/report/out/index.html +1 -1
  58. package/report/out/index.txt +3 -3
  59. package/report/out/viewports/__next._full.txt +3 -3
  60. package/report/out/viewports/__next._tree.txt +2 -2
  61. package/report/out/viewports/__next.viewports.__PAGE__.txt +3 -3
  62. package/report/out/viewports/index.html +1 -1
  63. package/report/out/viewports/index.txt +3 -3
  64. package/report/out/_next/static/chunks/679-a159f64a448d339d.js +0 -1
  65. package/report/out/_next/static/chunks/app/layout-16ba70b55e65864f.js +0 -1
  66. package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-d142454d28d73f8d.js +0 -1
  67. package/report/out/_next/static/css/7fbedb5502367c5e.css +0 -1
  68. /package/report/out/_next/static/{l8qlXIaCzWbRx2ZWOTp_i → TBRjO9Fdl2ToZdlLQ7gIf}/_buildManifest.js +0 -0
  69. /package/report/out/_next/static/{l8qlXIaCzWbRx2ZWOTp_i → TBRjO9Fdl2ToZdlLQ7gIf}/_ssgManifest.js +0 -0
package/dist/cli/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { persistedConfig, safeUrl } from "../utils/scenarios.js";
2
3
  import { readContract } from "../contract/read.js";
3
- import { validateScopes } from "../contract/scopes.js";
4
+ import { validateCoverage } from "../utils/coverage.js";
5
+ import { approvalCommands } from "./approvals.js";
6
+ import { readApprovals } from "../approvals/storage.js";
7
+ import { applyApprovals } from "../approvals/match.js";
4
8
  import { Command } from "commander";
5
9
  import { join, resolve } from "node:path";
6
10
  import { createInterface } from "node:readline/promises";
@@ -18,21 +22,26 @@ import { categories, } from "../types.js";
18
22
  const program = new Command()
19
23
  .name("tether")
20
24
  .description("Visual linting for AI-coded interfaces.")
21
- .version("0.3.0");
25
+ .version("0.4.0");
22
26
  program.option("--cwd <directory>", "Project directory", process.cwd());
23
27
  const cwd = () => resolve(program.opts().cwd);
24
28
  const directory = () => join(cwd(), ".tether");
25
- function reportFor(contract, baseline, current) {
26
- const issues = compare(contract, baseline, current);
29
+ function reportFor(contract, baseline, current, file) {
30
+ const result = file
31
+ ? applyApprovals(contract, baseline, current, file)
32
+ : { issues: compare(contract, baseline, current) };
33
+ const { issues } = result;
34
+ const approvals = "approvals" in result ? result.approvals : undefined;
27
35
  return {
28
- version: 1,
36
+ version: approvals ? 2 : 1,
37
+ ...(approvals ? { approvals } : {}),
29
38
  baselineAt: baseline.createdAt,
30
39
  scan: current,
31
40
  contract,
32
41
  issues,
33
42
  score: consistencyScore(issues),
34
43
  elementCount: current.snapshots.reduce((n, s) => n + s.elements.length, 0),
35
- agentMarkdown: agentMarkdown(issues),
44
+ agentMarkdown: agentMarkdown(issues, approvals),
36
45
  };
37
46
  }
38
47
  function warnTruncated(report) {
@@ -64,20 +73,21 @@ program
64
73
  prompt.close();
65
74
  }
66
75
  }
76
+ const approvalFile = await readApprovals(cwd());
67
77
  const config = await loadConfig(cwd(), options.url, options.config);
68
- console.log(`\ntether\n\nScanning ${config.url}…`);
78
+ console.log(`\ntether\n\nScanning ${safeUrl(config.url)}…`);
69
79
  const baseline = await scan(config, directory(), `baseline-${randomUUID().slice(0, 8)}`);
70
80
  const contract = inferContract(baseline, config.minOccurrences, config.scopes);
71
81
  // Save the exact scan configuration in the baseline so subsequent checks match it.
72
82
  await writeJson(join(directory(), "contract.json"), contract);
73
83
  await writeJson(join(directory(), "baseline.json"), {
74
84
  ...baseline,
75
- config,
85
+ config: persistedConfig(config),
76
86
  });
77
- const report = reportFor(contract, baseline, baseline);
87
+ const report = reportFor(contract, baseline, baseline, approvalFile);
78
88
  await writeJson(join(directory(), "report.json"), report);
79
89
  if (!(await exists(join(cwd(), "tether.config.ts"))) && !options.config)
80
- await writeFile(join(cwd(), "tether.config.ts"), `export default ${JSON.stringify(config, null, 2)};\n`);
90
+ await writeFile(join(cwd(), "tether.config.ts"), `export default ${JSON.stringify(persistedConfig(config), null, 2).replace(JSON.stringify(safeUrl(config.url)), safeUrl(config.url) !== config.url ? "process.env.TETHER_URL" : JSON.stringify(config.url))};\n`);
81
91
  console.log(`\n✓ Baseline saved\n✓ ${categories.reduce((n, c) => n + contract[c].length, 0)} tokens in your visual contract\n✓ ${report.elementCount} elements inspected across ${baseline.snapshots.length} snapshots\n\nRun: tether check\n`);
82
92
  if (report.issues.length)
83
93
  console.log(`Note: ${report.issues.length} existing visual deviations are visible in tether report.`);
@@ -103,24 +113,17 @@ program
103
113
  categories.some((c) => !Array.isArray(contract[c]) ||
104
114
  contract[c].some((t) => typeof t.value !== "string")))
105
115
  throw new Error("Unsupported or invalid baseline / contract. Run tether init --force to recreate it.");
116
+ const approvalFile = await readApprovals(cwd());
106
117
  const config = await loadConfig(cwd(), options.url, options.config);
107
118
  if (!options.url &&
108
119
  !options.config &&
109
120
  !(await exists(join(cwd(), "tether.config.ts"))))
110
121
  config.url = baseline.url;
111
- if (JSON.stringify(validateScopes(config.scopes ?? [])) !==
112
- JSON.stringify(validateScopes(contract.scopes ?? [])))
113
- throw new Error("Component scopes or overrides changed since the baseline. Run tether init --force to establish the new contract.");
114
- const coverage = (s) => `${s.route}|${s.viewport.name}|${s.viewport.width}|${s.viewport.height}`;
115
- const baselineCoverage = new Set(baseline.snapshots.map(coverage));
116
- const requested = config.routes.flatMap((route) => config.viewports.map((viewport) => coverage({ route, viewport })));
117
- if (requested.length !== baselineCoverage.size ||
118
- requested.some((k) => !baselineCoverage.has(k)))
119
- throw new Error("Routes or viewports changed since the baseline. Run tether init --force to establish the new coverage.");
122
+ validateCoverage(config, contract, baseline);
120
123
  if (!options.agent && !options.json)
121
- console.log(`\nScanning ${config.url}…\n`);
124
+ console.log(`\nScanning ${safeUrl(config.url)}…\n`);
122
125
  const current = await scan(config, directory(), `scan-${randomUUID().slice(0, 8)}`);
123
- const report = reportFor(contract, baseline, current);
126
+ const report = reportFor(contract, baseline, current, approvalFile);
124
127
  await writeJson(join(directory(), "report.json"), report);
125
128
  console.log(options.json
126
129
  ? JSON.stringify(jsonReport(report, baseline), null, 2)
@@ -142,6 +145,7 @@ program
142
145
  throw new Error("Port must be 0 or an integer between 1024 and 65535.");
143
146
  await serveReport(directory(), port, options.open);
144
147
  });
148
+ approvalCommands(program, cwd);
145
149
  program.parseAsync().catch((error) => {
146
150
  console.error(`\ntether: ${error.message}\n`);
147
151
  process.exitCode = 2;
@@ -2,7 +2,7 @@ import { type Report, type Scan } from "../types.js";
2
2
  /** Screenshot references are local JSON pointers, not random scan directory names. */
3
3
  export declare function jsonReport(report: Report, baseline: Scan): {
4
4
  schema: string;
5
- version: number;
5
+ version: 1;
6
6
  summary: {
7
7
  total: number;
8
8
  severity: {
@@ -13,6 +13,23 @@ export declare function jsonReport(report: Report, baseline: Scan): {
13
13
  };
14
14
  };
15
15
  findings: {
16
+ viewports: string[];
17
+ property: string;
18
+ current: string;
19
+ expected: string[];
20
+ evidence: {
21
+ baseline?: {
22
+ bounds?: import("../types.js").Bounds | undefined;
23
+ screenshotRef: string | null;
24
+ } | undefined;
25
+ current: {
26
+ bounds?: import("../types.js").Bounds | undefined;
27
+ screenshotRef: string | null;
28
+ };
29
+ state?: string | undefined;
30
+ viewport: import("../types.js").Viewport;
31
+ }[];
32
+ state?: string | undefined;
16
33
  identity: string;
17
34
  identityConfidence: import("../types.js").IdentityConfidence;
18
35
  selector: string;
@@ -20,6 +37,37 @@ export declare function jsonReport(report: Report, baseline: Scan): {
20
37
  category: "colors" | "fontFamilies" | "fontSizes" | "fontWeights" | "lineHeights" | "spacing" | "radii" | "shadows" | "borders" | "elementHeights" | "layout";
21
38
  severity: import("../types.js").Severity;
22
39
  route: string;
40
+ rule?: import("../types.js").RuleProvenance | undefined;
41
+ id: string;
42
+ }[];
43
+ staleApprovals?: undefined;
44
+ } | {
45
+ schema: string;
46
+ version: 2;
47
+ summary: {
48
+ total: number;
49
+ active: {
50
+ total: number;
51
+ severity: {
52
+ [k: string]: number;
53
+ };
54
+ category: {
55
+ [k: string]: number;
56
+ };
57
+ };
58
+ approved: {
59
+ total: number;
60
+ severity: {
61
+ [k: string]: number;
62
+ };
63
+ category: {
64
+ [k: string]: number;
65
+ };
66
+ };
67
+ staleApprovals: number;
68
+ };
69
+ findings: {
70
+ status: string;
23
71
  viewports: string[];
24
72
  property: string;
25
73
  current: string;
@@ -29,13 +77,23 @@ export declare function jsonReport(report: Report, baseline: Scan): {
29
77
  bounds?: import("../types.js").Bounds | undefined;
30
78
  screenshotRef: string | null;
31
79
  } | undefined;
32
- viewport: import("../types.js").Viewport;
33
80
  current: {
34
81
  bounds?: import("../types.js").Bounds | undefined;
35
82
  screenshotRef: string | null;
36
83
  };
84
+ state?: string | undefined;
85
+ viewport: import("../types.js").Viewport;
37
86
  }[];
87
+ state?: string | undefined;
88
+ identity: string;
89
+ identityConfidence: import("../types.js").IdentityConfidence;
90
+ selector: string;
91
+ title: string;
92
+ category: "colors" | "fontFamilies" | "fontSizes" | "fontWeights" | "lineHeights" | "spacing" | "radii" | "shadows" | "borders" | "elementHeights" | "layout";
93
+ severity: import("../types.js").Severity;
94
+ route: string;
38
95
  rule?: import("../types.js").RuleProvenance | undefined;
39
96
  id: string;
40
97
  }[];
98
+ staleApprovals: import("../types.js").Approval[];
41
99
  };
package/dist/cli/json.js CHANGED
@@ -9,51 +9,76 @@ export function jsonReport(report, baseline) {
9
9
  (file === "report.json" ? "scan/" : "") +
10
10
  `snapshots/${index}/screenshot`;
11
11
  };
12
+ const finding = (i) => ({
13
+ id: i.id,
14
+ ...(i.rule ? { rule: i.rule } : {}),
15
+ identity: i.identity ?? `selector:${i.selector}`,
16
+ identityConfidence: i.identityConfidence ?? "low",
17
+ selector: i.selector,
18
+ title: i.title,
19
+ category: i.category,
20
+ severity: i.severity,
21
+ route: i.route,
22
+ ...(i.state ? { state: i.state } : {}),
23
+ viewports: [...i.viewports].sort(),
24
+ property: i.property,
25
+ current: i.current,
26
+ expected: [...i.expected].sort(),
27
+ evidence: [...(i.evidence ?? [])]
28
+ .sort((a, b) => a.viewport.name.localeCompare(b.viewport.name))
29
+ .map((e) => ({
30
+ viewport: e.viewport,
31
+ ...(e.state ? { state: e.state } : {}),
32
+ current: {
33
+ screenshotRef: reference(report.scan, "report.json", e.current.screenshot),
34
+ ...(e.current.bounds ? { bounds: e.current.bounds } : {}),
35
+ },
36
+ ...(e.baseline
37
+ ? {
38
+ baseline: {
39
+ screenshotRef: reference(baseline, "baseline.json", e.baseline.screenshot),
40
+ ...(e.baseline.bounds ? { bounds: e.baseline.bounds } : {}),
41
+ },
42
+ }
43
+ : {}),
44
+ })),
45
+ });
46
+ const summary = (issues) => ({
47
+ total: issues.length,
48
+ severity: Object.fromEntries(["high", "medium", "low"].map((s) => [
49
+ s,
50
+ issues.filter((i) => i.severity === s).length,
51
+ ])),
52
+ category: Object.fromEntries([...categories, "layout"].map((c) => [
53
+ c,
54
+ issues.filter((i) => i.category === c).length,
55
+ ])),
56
+ });
57
+ if (!report.approvals)
58
+ return {
59
+ schema: "tether.check",
60
+ version: 1,
61
+ summary: summary(report.issues),
62
+ findings: report.issues.map(finding),
63
+ };
64
+ const approved = report.approvals.approved;
12
65
  return {
13
66
  schema: "tether.check",
14
- version: 1,
67
+ version: 2,
15
68
  summary: {
16
- total: report.issues.length,
17
- severity: Object.fromEntries(["high", "medium", "low"].map((s) => [
18
- s,
19
- report.issues.filter((i) => i.severity === s).length,
20
- ])),
21
- category: Object.fromEntries([...categories, "layout"].map((c) => [
22
- c,
23
- report.issues.filter((i) => i.category === c).length,
24
- ])),
69
+ total: report.issues.length + approved.length,
70
+ active: summary(report.issues),
71
+ approved: summary(approved),
72
+ staleApprovals: report.approvals.stale.length,
25
73
  },
26
- findings: report.issues.map((i) => ({
27
- id: i.id,
28
- ...(i.rule ? { rule: i.rule } : {}),
29
- identity: i.identity ?? `selector:${i.selector}`,
30
- identityConfidence: i.identityConfidence ?? "low",
31
- selector: i.selector,
32
- title: i.title,
33
- category: i.category,
34
- severity: i.severity,
35
- route: i.route,
36
- viewports: [...i.viewports].sort(),
37
- property: i.property,
38
- current: i.current,
39
- expected: [...i.expected].sort(),
40
- evidence: [...(i.evidence ?? [])]
41
- .sort((a, b) => a.viewport.name.localeCompare(b.viewport.name))
42
- .map((e) => ({
43
- viewport: e.viewport,
44
- current: {
45
- screenshotRef: reference(report.scan, "report.json", e.current.screenshot),
46
- ...(e.current.bounds ? { bounds: e.current.bounds } : {}),
47
- },
48
- ...(e.baseline
49
- ? {
50
- baseline: {
51
- screenshotRef: reference(baseline, "baseline.json", e.baseline.screenshot),
52
- ...(e.baseline.bounds ? { bounds: e.baseline.bounds } : {}),
53
- },
54
- }
55
- : {}),
74
+ findings: [
75
+ ...report.issues.map((i) => ({ ...finding(i), status: "active" })),
76
+ ...approved.map((i) => ({
77
+ ...finding(i),
78
+ status: "approved",
79
+ approval: i.approval,
56
80
  })),
57
- })),
81
+ ],
82
+ staleApprovals: report.approvals.stale,
58
83
  };
59
84
  }
@@ -1,5 +1,8 @@
1
- import type { Category, Contract, Issue, Scan, Severity } from "../types.js";
1
+ import type { Category, Contract, Issue, Scan, Severity, FindingOccurrence } from "../types.js";
2
2
  export declare const labels: Record<Category | "layout", string>;
3
3
  export declare function severityFor(category: Category, value: string, expected: string[]): Severity;
4
+ /** Atomic observations are retained until approval matching is complete. */
5
+ export declare function collectOccurrences(contract: Contract, baseline: Scan, current: Scan): FindingOccurrence[];
6
+ export declare function groupOccurrences(contract: Contract, occurrences: FindingOccurrence[]): Issue[];
4
7
  export declare function compare(contract: Contract, baseline: Scan, current: Scan): Issue[];
5
8
  export declare function consistencyScore(issues: Issue[]): number;
@@ -1,3 +1,4 @@
1
+ import { safeUrl } from "../utils/scenarios.js";
1
2
  import { resolveRule } from "../contract/scopes.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { baselineMatcher } from "../scanner/identity.js";
@@ -42,37 +43,42 @@ function equivalent(category, a, b) {
42
43
  /^-?[\d.]+px$/.test(b) &&
43
44
  Math.abs(parseFloat(a) - parseFloat(b)) <= 0.25);
44
45
  }
45
- export function compare(contract, baseline, current) {
46
- const issues = new Map();
46
+ /** Atomic observations are retained until approval matching is complete. */
47
+ export function collectOccurrences(contract, baseline, current) {
48
+ const occurrences = [];
49
+ let identities = new Map(), selectors = new Map();
50
+ let state = "default";
51
+ let documentOverflowUnique = false, completeSnapshot = true;
47
52
  function push(issue, viewport) {
48
- const key = [
49
- issue.category,
50
- issue.route,
51
- issue.identity ?? issue.selector,
52
- issue.current,
53
- issue.title,
54
- ]
55
- .concat(issue.rule ? [issue.property, JSON.stringify(issue.rule)] : [])
56
- .join("\0");
57
- const previous = issues.get(key);
58
- if (previous) {
59
- if (!previous.viewports.includes(viewport))
60
- previous.viewports.push(viewport);
61
- for (const evidence of issue.evidence ?? [])
62
- if (!previous.evidence?.some((e) => e.viewport.name === evidence.viewport.name))
63
- previous.evidence?.push(evidence);
64
- if (!previous.property.split(", ").includes(issue.property))
65
- previous.property += `, ${issue.property}`;
66
- }
67
- else
68
- issues.set(key, {
69
- ...issue,
70
- id: createHash("sha256").update(key).digest("hex").slice(0, 12),
71
- viewports: [viewport],
72
- });
53
+ occurrences.push({
54
+ issue: { ...issue, ...(state !== "default" ? { state } : {}) },
55
+ viewport,
56
+ state,
57
+ identityUnique: completeSnapshot &&
58
+ issue.identityConfidence === "high" &&
59
+ identities.get(issue.identity ?? "") === 1,
60
+ selectorUnique: completeSnapshot &&
61
+ (selectors.get(issue.selector) === 1 ||
62
+ (issue.category === "layout" &&
63
+ issue.selector === "html" &&
64
+ issue.property === "overflow" &&
65
+ documentOverflowUnique)),
66
+ });
73
67
  }
74
68
  for (const snapshot of current.snapshots) {
75
- const previousSnapshot = baseline.snapshots.find((s) => s.route === snapshot.route &&
69
+ state = snapshot.state ?? "default";
70
+ completeSnapshot = !snapshot.truncated;
71
+ identities = new Map();
72
+ selectors = new Map();
73
+ for (const element of snapshot.elements) {
74
+ if (element.identity)
75
+ identities.set(element.identity, (identities.get(element.identity) ?? 0) + 1);
76
+ selectors.set(element.selector, (selectors.get(element.selector) ?? 0) + 1);
77
+ }
78
+ documentOverflowUnique =
79
+ snapshot.layout.filter((p) => p.selector === "html" && p.kind === "overflow").length === 1;
80
+ const previousSnapshot = baseline.snapshots.find((s) => safeUrl(s.route) === safeUrl(snapshot.route) &&
81
+ (s.state ?? "default") === state &&
76
82
  s.viewport.name === snapshot.viewport.name &&
77
83
  s.viewport.width === snapshot.viewport.width &&
78
84
  s.viewport.height === snapshot.viewport.height);
@@ -107,7 +113,7 @@ export function compare(contract, baseline, current) {
107
113
  current: o.value,
108
114
  expected,
109
115
  route: snapshot.route,
110
- }, snapshot.viewport.name);
116
+ }, snapshot.viewport);
111
117
  }
112
118
  }
113
119
  for (const problem of snapshot.layout) {
@@ -130,9 +136,45 @@ export function compare(contract, baseline, current) {
130
136
  current: problem.value,
131
137
  expected: ["Content fits within the viewport"],
132
138
  route: snapshot.route,
133
- }, snapshot.viewport.name);
139
+ }, snapshot.viewport);
140
+ }
141
+ }
142
+ return occurrences;
143
+ }
144
+ export function groupOccurrences(contract, occurrences) {
145
+ const issues = new Map();
146
+ function push(issue, viewport) {
147
+ const key = [
148
+ issue.category,
149
+ issue.route,
150
+ issue.identity ?? issue.selector,
151
+ issue.current,
152
+ issue.title,
153
+ ]
154
+ .concat(issue.state && issue.state !== "default" ? [issue.state] : [])
155
+ .concat(issue.rule ? [issue.property, JSON.stringify(issue.rule)] : [])
156
+ .concat(issue.approval ? [issue.property, issue.approval.id] : [])
157
+ .join("\0");
158
+ const previous = issues.get(key);
159
+ if (previous) {
160
+ if (!previous.viewports.includes(viewport))
161
+ previous.viewports.push(viewport);
162
+ for (const evidence of issue.evidence ?? [])
163
+ if (!previous.evidence?.some((e) => e.viewport.name === evidence.viewport.name))
164
+ previous.evidence?.push(evidence);
165
+ if (!previous.property.split(", ").includes(issue.property))
166
+ previous.property += `, ${issue.property}`;
134
167
  }
168
+ else
169
+ issues.set(key, {
170
+ ...issue,
171
+ ...(issue.evidence ? { evidence: [...issue.evidence] } : {}),
172
+ id: createHash("sha256").update(key).digest("hex").slice(0, 12),
173
+ viewports: [viewport],
174
+ });
135
175
  }
176
+ for (const occurrence of occurrences)
177
+ push(occurrence.issue, occurrence.viewport.name);
136
178
  const rank = { high: 0, medium: 1, low: 2 };
137
179
  if (contract.version === 2)
138
180
  for (const issue of issues.values()) {
@@ -142,6 +184,7 @@ export function compare(contract, baseline, current) {
142
184
  return [...issues.values()].sort((a, b) => rank[a.severity] - rank[b.severity] ||
143
185
  a.category.localeCompare(b.category) ||
144
186
  a.route.localeCompare(b.route) ||
187
+ (a.state ?? "default").localeCompare(b.state ?? "default") ||
145
188
  (a.identity ?? a.selector).localeCompare(b.identity ?? b.selector) ||
146
189
  a.current.localeCompare(b.current) ||
147
190
  (contract.version === 2
@@ -149,6 +192,9 @@ export function compare(contract, baseline, current) {
149
192
  JSON.stringify(a.rule ?? null).localeCompare(JSON.stringify(b.rule ?? null))
150
193
  : 0));
151
194
  }
195
+ export function compare(contract, baseline, current) {
196
+ return groupOccurrences(contract, collectOccurrences(contract, baseline, current));
197
+ }
152
198
  export function consistencyScore(issues) {
153
199
  return Math.max(0, 100 -
154
200
  issues.reduce((sum, i) => sum + { high: 8, medium: 4, low: 2 }[i.severity], 0));
@@ -24,6 +24,7 @@ export function visibleBounds(bounds, viewport) {
24
24
  export function findingEvidence(current, baseline, element, prior) {
25
25
  return {
26
26
  viewport: current.viewport,
27
+ ...(current.state ? { state: current.state } : {}),
27
28
  current: {
28
29
  screenshot: current.screenshot,
29
30
  ...(element?.bounds ? { bounds: element.bounds } : {}),
@@ -1,10 +1,11 @@
1
1
  import type { CollectedElement } from "./identity.js";
2
2
  import type { LayoutProblem, ScopeDefinition } from "../types.js";
3
3
  /** Pure DOM reader, serialized by Playwright into the page. */
4
- export declare function collect({ maxElements, ignore, scopes, }: {
4
+ export declare function collect({ maxElements, ignore, scopes, sensitiveSelectors, }: {
5
5
  maxElements: number;
6
6
  ignore: string[];
7
7
  scopes?: ScopeDefinition[];
8
+ sensitiveSelectors?: string[];
8
9
  }): {
9
10
  elements: CollectedElement[];
10
11
  layout: LayoutProblem[];
@@ -1,5 +1,5 @@
1
1
  /** Pure DOM reader, serialized by Playwright into the page. */
2
- export function collect({ maxElements, ignore, scopes = [], }) {
2
+ export function collect({ maxElements, ignore, scopes = [], sensitiveSelectors = [], }) {
3
3
  for (const selector of ignore) {
4
4
  try {
5
5
  document.querySelector(selector);
@@ -8,6 +8,16 @@ export function collect({ maxElements, ignore, scopes = [], }) {
8
8
  throw new Error(`Invalid ignore selector: ${selector}`);
9
9
  }
10
10
  }
11
+ const sensitive = sensitiveSelectors.flatMap((selector) => [
12
+ ...document.querySelectorAll(selector),
13
+ ]);
14
+ function privateElement(el) {
15
+ return (sensitive.some((s) => s.contains(el) || el.contains(s)) ||
16
+ (el.getAttribute("aria-labelledby") ?? "").split(/\s+/).some((id) => {
17
+ const label = document.getElementById(id);
18
+ return (label && sensitive.some((s) => s.contains(label) || label.contains(s)));
19
+ }));
20
+ }
11
21
  // Validate declarations even when the page contains no matching elements.
12
22
  for (const scope of scopes) {
13
23
  for (const selector of [
@@ -79,6 +89,8 @@ export function collect({ maxElements, ignore, scopes = [], }) {
79
89
  .join("");
80
90
  }
81
91
  function selectorFor(el) {
92
+ if (privateElement(el))
93
+ return structureFor(el);
82
94
  if (el.id && document.querySelectorAll(`#${escapeCss(el.id)}`).length === 1)
83
95
  return `#${escapeCss(el.id)}`;
84
96
  const testid = el.getAttribute("data-testid");
@@ -211,9 +223,9 @@ export function collect({ maxElements, ignore, scopes = [], }) {
211
223
  observations,
212
224
  bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
213
225
  signals: {
214
- attributes,
215
- text: el.textContent ?? "",
216
- classes: [...el.classList],
226
+ attributes: privateElement(el) ? {} : attributes,
227
+ text: privateElement(el) ? "" : (el.textContent ?? ""),
228
+ classes: privateElement(el) ? [] : [...el.classList],
217
229
  structure: structureFor(el),
218
230
  },
219
231
  });