tether-ui 0.1.0 → 0.3.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 (68) hide show
  1. package/README.md +82 -23
  2. package/dist/cli/format.js +4 -3
  3. package/dist/cli/index.js +21 -8
  4. package/dist/cli/json.d.ts +41 -0
  5. package/dist/cli/json.js +59 -0
  6. package/dist/contract/describe.d.ts +2 -0
  7. package/dist/contract/describe.js +6 -0
  8. package/dist/contract/infer.d.ts +2 -2
  9. package/dist/contract/infer.js +70 -3
  10. package/dist/contract/read.d.ts +3 -0
  11. package/dist/contract/read.js +67 -0
  12. package/dist/contract/scopes.d.ts +5 -0
  13. package/dist/contract/scopes.js +150 -0
  14. package/dist/drift/compare.js +52 -18
  15. package/dist/drift/evidence.d.ts +4 -0
  16. package/dist/drift/evidence.js +41 -0
  17. package/dist/scanner/collect.d.ts +5 -3
  18. package/dist/scanner/collect.js +78 -2
  19. package/dist/scanner/identity.d.ts +15 -0
  20. package/dist/scanner/identity.js +86 -0
  21. package/dist/scanner/scan.js +9 -1
  22. package/dist/types.d.ts +68 -1
  23. package/dist/utils/config.js +3 -0
  24. package/package.json +13 -4
  25. package/report/out/404/index.html +1 -1
  26. package/report/out/404.html +1 -1
  27. package/report/out/__next.__PAGE__.txt +3 -3
  28. package/report/out/__next._full.txt +4 -4
  29. package/report/out/__next._tree.txt +2 -2
  30. package/report/out/_next/static/chunks/679-a159f64a448d339d.js +1 -0
  31. package/report/out/_next/static/chunks/app/_not-found/{page-e3039971fa1f5f8d.js → page-032313fc4524b472.js} +1 -1
  32. package/report/out/_next/static/chunks/app/contract/{page-340f86944045adea.js → page-60f6d610ece63f59.js} +1 -1
  33. package/report/out/_next/static/chunks/app/drift/{page-559460ac1a19903b.js → page-655683d6032a79a8.js} +1 -1
  34. package/report/out/_next/static/chunks/app/layout-16ba70b55e65864f.js +1 -0
  35. package/report/out/_next/static/chunks/app/{page-ab86e7319bb488c8.js → page-bdfcce47bd844e7e.js} +1 -1
  36. package/report/out/_next/static/chunks/app/viewports/{page-8970bc5bd4e2e4f2.js → page-6e44561586c30871.js} +1 -1
  37. package/report/out/_next/static/chunks/main-app-e3082ce30f616b80.js +1 -0
  38. package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-d142454d28d73f8d.js +1 -0
  39. package/report/out/_next/static/css/7fbedb5502367c5e.css +1 -0
  40. package/report/out/_not-found/__next._full.txt +3 -3
  41. package/report/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  42. package/report/out/_not-found/__next._tree.txt +2 -2
  43. package/report/out/_not-found/index.html +1 -1
  44. package/report/out/_not-found/index.txt +3 -3
  45. package/report/out/contract/__next._full.txt +4 -4
  46. package/report/out/contract/__next._tree.txt +2 -2
  47. package/report/out/contract/__next.contract.__PAGE__.txt +3 -3
  48. package/report/out/contract/index.html +1 -1
  49. package/report/out/contract/index.txt +4 -4
  50. package/report/out/drift/__next._full.txt +4 -4
  51. package/report/out/drift/__next._tree.txt +2 -2
  52. package/report/out/drift/__next.drift.__PAGE__.txt +3 -3
  53. package/report/out/drift/index.html +1 -1
  54. package/report/out/drift/index.txt +4 -4
  55. package/report/out/index.html +1 -1
  56. package/report/out/index.txt +4 -4
  57. package/report/out/viewports/__next._full.txt +4 -4
  58. package/report/out/viewports/__next._tree.txt +2 -2
  59. package/report/out/viewports/__next.viewports.__PAGE__.txt +3 -3
  60. package/report/out/viewports/index.html +1 -1
  61. package/report/out/viewports/index.txt +4 -4
  62. package/report/out/_next/static/chunks/100-cde31512e90ea4ee.js +0 -1
  63. package/report/out/_next/static/chunks/app/layout-6c6cbe0311c0f643.js +0 -1
  64. package/report/out/_next/static/chunks/main-app-91d98c37495a2d56.js +0 -1
  65. package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-657c3d8e42266fe3.js +0 -1
  66. package/report/out/_next/static/css/b85889345c486052.css +0 -1
  67. /package/report/out/_next/static/{NEP_BMzhg1ixudz9w2f3I → l8qlXIaCzWbRx2ZWOTp_i}/_buildManifest.js +0 -0
  68. /package/report/out/_next/static/{NEP_BMzhg1ixudz9w2f3I → l8qlXIaCzWbRx2ZWOTp_i}/_ssgManifest.js +0 -0
@@ -0,0 +1,150 @@
1
+ import { categories, } from "../types.js";
2
+ import { normalizeValue } from "../utils/normalize.js";
3
+ const name = (s) => typeof s === "string" && /^[a-zA-Z][\w-]{0,63}$/.test(s);
4
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
5
+ const selector = (v) => typeof v === "string" && !!v.trim();
6
+ export function validProperty(category, property) {
7
+ if (typeof property !== "string")
8
+ return false;
9
+ const properties = {
10
+ colors: /^(color|background-color|border-color)$/,
11
+ fontFamilies: /^font-family$/,
12
+ fontSizes: /^font-size$/,
13
+ fontWeights: /^font-weight$/,
14
+ lineHeights: /^line-height$/,
15
+ spacing: /^(padding|margin)-(top|right|bottom|left)$|^(row|column)-gap$/,
16
+ radii: /^border-radius$/,
17
+ shadows: /^box-shadow$/,
18
+ borders: /^border$/,
19
+ elementHeights: /^height$/,
20
+ };
21
+ return !!properties[category]?.test(property);
22
+ }
23
+ /** Canonical, serializable declarations. CSS syntax is checked in the browser. */
24
+ export function validateScopes(input) {
25
+ if (!Array.isArray(input))
26
+ throw new Error("Config scopes must be an array.");
27
+ const names = new Set();
28
+ const selectors = new Set();
29
+ const result = [];
30
+ for (const s of input) {
31
+ if (!object(s) ||
32
+ !name(s.name) ||
33
+ !selector(s.selector) ||
34
+ names.has(s.name) ||
35
+ selectors.has(s.selector.trim()))
36
+ throw new Error("Scopes need unique names and distinct, nonempty CSS selectors.");
37
+ if (Object.keys(s).some((k) => ![
38
+ "name",
39
+ "selector",
40
+ "variant",
41
+ "minOccurrences",
42
+ "parts",
43
+ "overrides",
44
+ ].includes(k)))
45
+ throw new Error(`Unknown setting in scope ${s.name}.`);
46
+ if (s.variant !== undefined && !name(s.variant))
47
+ throw new Error(`Invalid variant for scope ${s.name}.`);
48
+ if (s.minOccurrences !== undefined &&
49
+ (!Number.isInteger(s.minOccurrences) ||
50
+ Number(s.minOccurrences) < 2 ||
51
+ Number(s.minOccurrences) > 1000))
52
+ throw new Error(`Scope ${s.name} minOccurrences must be between 2 and 1000; use an explicit override for a singleton.`);
53
+ if (s.parts !== undefined &&
54
+ (!object(s.parts) ||
55
+ Object.entries(s.parts).some(([k, v]) => !name(k) || k === "root" || !selector(v))))
56
+ throw new Error(`Scope ${s.name} parts need names (other than root) and CSS selectors.`);
57
+ const parts = Object.fromEntries(Object.entries(s.parts ?? {}).sort(([a], [b]) => a.localeCompare(b)));
58
+ if (new Set(Object.values(parts).map((v) => v.trim())).size !==
59
+ Object.keys(parts).length)
60
+ throw new Error(`Duplicate part selectors in ${s.name}.`);
61
+ if (s.overrides !== undefined && !Array.isArray(s.overrides))
62
+ throw new Error(`Scope ${s.name} overrides must be an array.`);
63
+ const keys = new Set();
64
+ const overrides = [];
65
+ for (const o of (s.overrides ?? [])) {
66
+ if (!object(o) ||
67
+ !categories.includes(o.category) ||
68
+ !validProperty(String(o.category), o.property) ||
69
+ !Array.isArray(o.values) ||
70
+ !o.values.length ||
71
+ o.values.some((v) => typeof v !== "string" || !v.trim()))
72
+ throw new Error(`Invalid property, category or values in ${s.name} override.`);
73
+ if (Object.keys(o).some((k) => !["part", "category", "property", "values"].includes(k)))
74
+ throw new Error(`Unknown override setting in ${s.name}.`);
75
+ const part = o.part ?? "root";
76
+ if (typeof part !== "string" ||
77
+ (part !== "root" && !Object.hasOwn(parts, part)))
78
+ throw new Error(`Unknown override part in ${s.name}.`);
79
+ const key = JSON.stringify([part, o.category, o.property]);
80
+ if (keys.has(key))
81
+ throw new Error(`Duplicate override for ${s.name}/${part}/${o.property}.`);
82
+ keys.add(key);
83
+ const values = [
84
+ ...new Set(o.values.map((v) => normalizeValue(o.property, v))),
85
+ ].sort();
86
+ if (values.some((v) => ["transparent", "0px", "none", "normal", "auto"].includes(v)))
87
+ throw new Error(`Override for ${s.name} contains a value the scanner does not collect.`);
88
+ overrides.push({
89
+ part,
90
+ category: o.category,
91
+ property: o.property,
92
+ values,
93
+ });
94
+ }
95
+ names.add(s.name);
96
+ selectors.add(s.selector.trim());
97
+ result.push({
98
+ name: s.name,
99
+ selector: s.selector.trim(),
100
+ ...(s.variant ? { variant: s.variant } : {}),
101
+ ...(s.minOccurrences !== undefined
102
+ ? { minOccurrences: Number(s.minOccurrences) }
103
+ : {}),
104
+ parts,
105
+ overrides: overrides.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
106
+ });
107
+ }
108
+ return result.sort((a, b) => a.name.localeCompare(b.name));
109
+ }
110
+ export function resolveRule(contract, element, observation) {
111
+ if (contract.version === 1)
112
+ return;
113
+ const membership = element.scope;
114
+ const definition = contract.scopes?.find((s) => s.name === membership?.name);
115
+ const rule = contract.rules?.find((r) => r.scope === membership?.name &&
116
+ r.part === membership?.part &&
117
+ r.category === observation.category &&
118
+ r.property === observation.property);
119
+ const context = membership
120
+ ? {
121
+ scope: membership.name,
122
+ ...(definition?.variant ? { variant: definition.variant } : {}),
123
+ part: membership.part,
124
+ }
125
+ : {};
126
+ const threshold = definition?.minOccurrences ?? Math.max(2, contract.minOccurrences);
127
+ const supported = rule?.tokens.filter((t) => rule.source === "override" || t.count >= threshold) ?? [];
128
+ if (rule &&
129
+ supported.length &&
130
+ (rule.source === "override" || !contract.partial))
131
+ return {
132
+ ...context,
133
+ source: rule.source,
134
+ samples: rule.samples,
135
+ tokens: supported,
136
+ };
137
+ return {
138
+ ...context,
139
+ source: "global",
140
+ reason: !membership
141
+ ? "unscoped"
142
+ : contract.partial
143
+ ? "partial-baseline"
144
+ : !rule
145
+ ? "missing-rule"
146
+ : "insufficient-support",
147
+ samples: rule?.samples ?? 0,
148
+ tokens: contract[observation.category],
149
+ };
150
+ }
@@ -1,4 +1,7 @@
1
+ import { resolveRule } from "../contract/scopes.js";
1
2
  import { createHash } from "node:crypto";
3
+ import { baselineMatcher } from "../scanner/identity.js";
4
+ import { findingEvidence } from "./evidence.js";
2
5
  import { colorDistance } from "../utils/normalize.js";
3
6
  export const labels = {
4
7
  colors: "Unexpected color",
@@ -41,27 +44,23 @@ function equivalent(category, a, b) {
41
44
  }
42
45
  export function compare(contract, baseline, current) {
43
46
  const issues = new Map();
44
- const exceptions = new Map();
45
- for (const s of baseline.snapshots)
46
- for (const e of s.elements)
47
- for (const o of e.observations) {
48
- const key = `${s.route}\0${s.viewport.name}\0${e.selector}\0${o.property}\0${o.category}`;
49
- const values = exceptions.get(key) ?? new Set();
50
- values.add(o.value);
51
- exceptions.set(key, values);
52
- }
53
47
  function push(issue, viewport) {
54
48
  const key = [
55
49
  issue.category,
56
50
  issue.route,
57
- issue.selector,
51
+ issue.identity ?? issue.selector,
58
52
  issue.current,
59
53
  issue.title,
60
- ].join("\0");
54
+ ]
55
+ .concat(issue.rule ? [issue.property, JSON.stringify(issue.rule)] : [])
56
+ .join("\0");
61
57
  const previous = issues.get(key);
62
58
  if (previous) {
63
59
  if (!previous.viewports.includes(viewport))
64
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);
65
64
  if (!previous.property.split(", ").includes(issue.property))
66
65
  previous.property += `, ${issue.property}`;
67
66
  }
@@ -73,26 +72,46 @@ export function compare(contract, baseline, current) {
73
72
  });
74
73
  }
75
74
  for (const snapshot of current.snapshots) {
76
- for (const element of snapshot.elements)
75
+ const previousSnapshot = baseline.snapshots.find((s) => s.route === snapshot.route &&
76
+ s.viewport.name === snapshot.viewport.name &&
77
+ s.viewport.width === snapshot.viewport.width &&
78
+ s.viewport.height === snapshot.viewport.height);
79
+ const match = baselineMatcher(previousSnapshot?.elements ?? []);
80
+ for (const element of snapshot.elements) {
81
+ const prior = match(element);
77
82
  for (const o of element.observations) {
78
- const expected = contract[o.category].map((t) => t.value);
83
+ const rule = resolveRule(contract, element, o);
84
+ const expected = (rule?.tokens ?? contract[o.category]).map((t) => t.value);
79
85
  if (expected.some((v) => equivalent(o.category, v, o.value)))
80
86
  continue;
81
- const prior = exceptions.get(`${snapshot.route}\0${snapshot.viewport.name}\0${element.selector}\0${o.property}\0${o.category}`);
82
- if (prior && [...prior].some((v) => equivalent(o.category, v, o.value)))
87
+ if (rule?.source !== "override" &&
88
+ (contract.version === 1 ||
89
+ (prior?.scope?.name === element.scope?.name &&
90
+ prior?.scope?.part === element.scope?.part)) &&
91
+ prior?.observations.some((p) => p.category === o.category &&
92
+ p.property === o.property &&
93
+ equivalent(o.category, p.value, o.value)))
83
94
  continue;
84
95
  push({
96
+ ...(rule ? { rule } : {}),
85
97
  category: o.category,
86
98
  severity: severityFor(o.category, o.value, expected),
87
99
  title: labels[o.category],
88
100
  selector: element.selector,
101
+ identity: element.identity ?? `selector:${element.selector}`,
102
+ identityConfidence: element.identityConfidence ?? "low",
103
+ evidence: [
104
+ findingEvidence(snapshot, previousSnapshot, element, prior),
105
+ ],
89
106
  property: o.property,
90
107
  current: o.value,
91
108
  expected,
92
109
  route: snapshot.route,
93
110
  }, snapshot.viewport.name);
94
111
  }
95
- for (const problem of snapshot.layout)
112
+ }
113
+ for (const problem of snapshot.layout) {
114
+ const element = snapshot.elements.find((e) => e.selector === problem.selector);
96
115
  push({
97
116
  category: "layout",
98
117
  severity: problem.severity,
@@ -102,18 +121,33 @@ export function compare(contract, baseline, current) {
102
121
  ? "Clipped content"
103
122
  : "Element outside viewport",
104
123
  selector: problem.selector,
124
+ identity: element?.identity ?? `selector:${problem.selector}`,
125
+ identityConfidence: element?.identityConfidence ?? "low",
126
+ evidence: [
127
+ findingEvidence(snapshot, previousSnapshot, element, element ? match(element) : undefined),
128
+ ],
105
129
  property: problem.kind,
106
130
  current: problem.value,
107
131
  expected: ["Content fits within the viewport"],
108
132
  route: snapshot.route,
109
133
  }, snapshot.viewport.name);
134
+ }
110
135
  }
111
136
  const rank = { high: 0, medium: 1, low: 2 };
137
+ if (contract.version === 2)
138
+ for (const issue of issues.values()) {
139
+ issue.viewports.sort();
140
+ issue.evidence?.sort((a, b) => a.viewport.name.localeCompare(b.viewport.name));
141
+ }
112
142
  return [...issues.values()].sort((a, b) => rank[a.severity] - rank[b.severity] ||
113
143
  a.category.localeCompare(b.category) ||
114
144
  a.route.localeCompare(b.route) ||
115
- a.selector.localeCompare(b.selector) ||
116
- a.current.localeCompare(b.current));
145
+ (a.identity ?? a.selector).localeCompare(b.identity ?? b.selector) ||
146
+ a.current.localeCompare(b.current) ||
147
+ (contract.version === 2
148
+ ? a.property.localeCompare(b.property) ||
149
+ JSON.stringify(a.rule ?? null).localeCompare(JSON.stringify(b.rule ?? null))
150
+ : 0));
117
151
  }
118
152
  export function consistencyScore(issues) {
119
153
  return Math.max(0, 100 -
@@ -0,0 +1,4 @@
1
+ import type { Bounds, ElementRecord, Evidence, Snapshot, Viewport } from "../types.js";
2
+ /** Keep only the visible part; below-fold, invalid and absent bounds have no overlay. */
3
+ export declare function visibleBounds(bounds: Bounds | undefined, viewport: Viewport): Bounds | undefined;
4
+ export declare function findingEvidence(current: Snapshot, baseline?: Snapshot, element?: ElementRecord, prior?: ElementRecord): Evidence;
@@ -0,0 +1,41 @@
1
+ /** Keep only the visible part; below-fold, invalid and absent bounds have no overlay. */
2
+ export function visibleBounds(bounds, viewport) {
3
+ if (!bounds ||
4
+ ![
5
+ bounds.x,
6
+ bounds.y,
7
+ bounds.width,
8
+ bounds.height,
9
+ viewport.width,
10
+ viewport.height,
11
+ ].every(Number.isFinite) ||
12
+ bounds.width <= 0 ||
13
+ bounds.height <= 0 ||
14
+ viewport.width <= 0 ||
15
+ viewport.height <= 0)
16
+ return;
17
+ const x = Math.max(0, bounds.x), y = Math.max(0, bounds.y);
18
+ const right = Math.min(viewport.width, bounds.x + bounds.width);
19
+ const bottom = Math.min(viewport.height, bounds.y + bounds.height);
20
+ if (right <= x || bottom <= y)
21
+ return;
22
+ return { x, y, width: right - x, height: bottom - y };
23
+ }
24
+ export function findingEvidence(current, baseline, element, prior) {
25
+ return {
26
+ viewport: current.viewport,
27
+ current: {
28
+ screenshot: current.screenshot,
29
+ ...(element?.bounds ? { bounds: element.bounds } : {}),
30
+ },
31
+ // A baseline capture is useful even when no exact element match is available.
32
+ ...(baseline
33
+ ? {
34
+ baseline: {
35
+ screenshot: baseline.screenshot,
36
+ ...(prior?.bounds ? { bounds: prior.bounds } : {}),
37
+ },
38
+ }
39
+ : {}),
40
+ };
41
+ }
@@ -1,10 +1,12 @@
1
- import type { ElementRecord, LayoutProblem } from "../types.js";
1
+ import type { CollectedElement } from "./identity.js";
2
+ import type { LayoutProblem, ScopeDefinition } from "../types.js";
2
3
  /** Pure DOM reader, serialized by Playwright into the page. */
3
- export declare function collect({ maxElements, ignore, }: {
4
+ export declare function collect({ maxElements, ignore, scopes, }: {
4
5
  maxElements: number;
5
6
  ignore: string[];
7
+ scopes?: ScopeDefinition[];
6
8
  }): {
7
- elements: ElementRecord[];
9
+ elements: CollectedElement[];
8
10
  layout: LayoutProblem[];
9
11
  truncated: boolean;
10
12
  };
@@ -1,5 +1,5 @@
1
1
  /** Pure DOM reader, serialized by Playwright into the page. */
2
- export function collect({ maxElements, ignore, }) {
2
+ export function collect({ maxElements, ignore, scopes = [], }) {
3
3
  for (const selector of ignore) {
4
4
  try {
5
5
  document.querySelector(selector);
@@ -8,6 +8,49 @@ export function collect({ maxElements, ignore, }) {
8
8
  throw new Error(`Invalid ignore selector: ${selector}`);
9
9
  }
10
10
  }
11
+ // Validate declarations even when the page contains no matching elements.
12
+ for (const scope of scopes) {
13
+ for (const selector of [
14
+ scope.selector,
15
+ ...Object.values(scope.parts ?? {}),
16
+ ]) {
17
+ try {
18
+ document.querySelector(selector);
19
+ }
20
+ catch {
21
+ throw new Error(`Invalid selector in scope ${scope.name}: ${selector}`);
22
+ }
23
+ }
24
+ }
25
+ function scopeFor(el) {
26
+ for (let root = el; root; root = root.parentElement) {
27
+ const matches = scopes.filter((s) => root.matches(s.selector));
28
+ if (matches.length > 1)
29
+ throw new Error(`Ambiguous scopes on ${selectorFor(root)}: ${matches
30
+ .map((s) => s.name)
31
+ .sort()
32
+ .join(", ")}`);
33
+ if (!matches.length)
34
+ continue;
35
+ const scope = matches[0];
36
+ for (let node = el; node; node = node.parentElement) {
37
+ const parts = Object.entries(scope.parts ?? {}).filter(([, selector]) => node.matches(selector));
38
+ if (parts.length > 1)
39
+ throw new Error(`Ambiguous parts in ${scope.name} on ${selectorFor(node)}.`);
40
+ if (parts.length || node === root) {
41
+ // Unnamed descendants do not inherit container padding/radius conventions.
42
+ if (!parts.length && el !== root)
43
+ return;
44
+ return {
45
+ name: scope.name,
46
+ ...(scope.variant ? { variant: scope.variant } : {}),
47
+ part: parts[0]?.[0] ?? "root",
48
+ instance: selectorFor(root),
49
+ };
50
+ }
51
+ }
52
+ }
53
+ }
11
54
  const excluded = ignore.length ? ignore.join(",") : null;
12
55
  function visible(el) {
13
56
  if (excluded && el.closest(excluded))
@@ -48,6 +91,9 @@ export function collect({ maxElements, ignore, }) {
48
91
  for (const name of classes)
49
92
  if (document.querySelectorAll(`.${escapeCss(name)}`).length === 1)
50
93
  return `.${escapeCss(name)}`;
94
+ return structureFor(el);
95
+ }
96
+ function structureFor(el) {
51
97
  const parts = [];
52
98
  let node = el;
53
99
  while (node && node !== document.body) {
@@ -140,7 +186,37 @@ export function collect({ maxElements, ignore, }) {
140
186
  add("shadows", "box-shadow");
141
187
  if (control)
142
188
  add("elementHeights", "height", `${rect.height}px`);
143
- elements.push({ selector, tag: el.tagName.toLowerCase(), observations });
189
+ const attributes = Object.fromEntries([
190
+ "id",
191
+ "data-testid",
192
+ "data-test",
193
+ "data-cy",
194
+ "role",
195
+ "aria-label",
196
+ "name",
197
+ "alt",
198
+ "href",
199
+ ].map((name) => [name, el.getAttribute(name) ?? ""]));
200
+ const labelledBy = el.getAttribute("aria-labelledby");
201
+ if (labelledBy)
202
+ attributes["aria-label"] = labelledBy
203
+ .split(/\s+/)
204
+ .map((id) => document.getElementById(id)?.textContent ?? "")
205
+ .join(" ");
206
+ const scope = scopeFor(el);
207
+ elements.push({
208
+ ...(scope ? { scope } : {}),
209
+ selector,
210
+ tag: el.tagName.toLowerCase(),
211
+ observations,
212
+ bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
213
+ signals: {
214
+ attributes,
215
+ text: el.textContent ?? "",
216
+ classes: [...el.classList],
217
+ structure: structureFor(el),
218
+ },
219
+ });
144
220
  const outside = Math.max(0, rect.right - innerWidth, -rect.left);
145
221
  if (outside > 4 &&
146
222
  !scrollContainer(el) &&
@@ -0,0 +1,15 @@
1
+ import type { ElementRecord, IdentityConfidence } from "../types.js";
2
+ export interface IdentitySignals {
3
+ attributes: Record<string, string>;
4
+ text: string;
5
+ classes: string[];
6
+ structure?: string;
7
+ }
8
+ export type CollectedElement = ElementRecord & {
9
+ signals: IdentitySignals;
10
+ };
11
+ export declare function identityCandidates(element: CollectedElement): [string, IdentityConfidence][];
12
+ /** Only unique signals identify an element; ambiguous matches use the selector. */
13
+ export declare function assignIdentities(elements: CollectedElement[]): ElementRecord[];
14
+ /** No selector rescue when two new records have different semantic identities. */
15
+ export declare function baselineMatcher(elements: ElementRecord[]): (current: ElementRecord) => ElementRecord | undefined;
@@ -0,0 +1,86 @@
1
+ const concise = (value) => {
2
+ const text = value.replace(/\s+/g, " ").trim();
3
+ return text.length <= 80 ? text : "";
4
+ };
5
+ // Obvious counters, framework IDs and CSS module hashes are not semantic anchors.
6
+ const stable = (value) => !!value &&
7
+ value.length <= 80 &&
8
+ !/\d{3,}|[a-f\d]{8,}|(^|[-_:])(css|sc|radix|react|uid)[-_:]|__|:[rR]|[a-z][A-Z0-9]{5,}/.test(value);
9
+ export function identityCandidates(element) {
10
+ const { attributes: a, classes, text } = element.signals;
11
+ const result = [];
12
+ const add = (kind, value, confidence) => {
13
+ if (value)
14
+ result.push([JSON.stringify([kind, value]), confidence]);
15
+ };
16
+ if (stable(a.id ?? ""))
17
+ add("id", a.id, "high");
18
+ for (const attr of ["data-testid", "data-test", "data-cy"])
19
+ if (stable(a[attr] ?? ""))
20
+ add(attr, a[attr], "high");
21
+ const role = a.role ||
22
+ {
23
+ button: "button",
24
+ a: "link",
25
+ input: "input",
26
+ select: "select",
27
+ textarea: "textbox",
28
+ img: "img",
29
+ }[element.tag] ||
30
+ element.tag;
31
+ for (const attr of ["aria-label", "name", "alt"])
32
+ add(`${role}:${attr}`, concise(a[attr] ?? ""), "medium");
33
+ if (element.tag === "a" && a.href && !/[?#]/.test(a.href))
34
+ add("link:href", concise(a.href), "medium");
35
+ if (/^(button|a|label|h[1-6]|option)$/.test(element.tag)) {
36
+ const label = concise(text);
37
+ if (!/\d/.test(label))
38
+ add(`${role}:text`, label, "medium");
39
+ }
40
+ for (const name of [...classes].sort())
41
+ if (stable(name) && /^[a-zA-Z][\w-]*$/.test(name))
42
+ add(`${element.tag}:class`, name, "medium");
43
+ return result;
44
+ }
45
+ /** Only unique signals identify an element; ambiguous matches use the selector. */
46
+ export function assignIdentities(elements) {
47
+ const candidates = elements.map(identityCandidates);
48
+ const counts = new Map();
49
+ for (const list of candidates)
50
+ for (const key of new Set(list.map(([key]) => key)))
51
+ counts.set(key, (counts.get(key) ?? 0) + 1);
52
+ return elements.map(({ signals: _signals, ...element }, index) => {
53
+ const unique = candidates[index].find(([key]) => counts.get(key) === 1);
54
+ return {
55
+ ...element,
56
+ identity: unique?.[0] ?? `structure:${_signals.structure ?? element.selector}`,
57
+ identityConfidence: unique?.[1] ?? "low",
58
+ };
59
+ });
60
+ }
61
+ /** No selector rescue when two new records have different semantic identities. */
62
+ export function baselineMatcher(elements) {
63
+ const identities = new Map();
64
+ const selectors = new Map();
65
+ for (const element of elements) {
66
+ if (element.identity)
67
+ identities.set(element.identity, [
68
+ ...(identities.get(element.identity) ?? []),
69
+ element,
70
+ ]);
71
+ selectors.set(element.selector, [
72
+ ...(selectors.get(element.selector) ?? []),
73
+ element,
74
+ ]);
75
+ }
76
+ return (current) => {
77
+ const semantic = current.identity
78
+ ? identities.get(current.identity)
79
+ : undefined;
80
+ if (semantic?.length === 1)
81
+ return semantic[0];
82
+ const fallback = selectors.get(current.selector);
83
+ if (fallback?.length === 1 && (!current.identity || !fallback[0].identity))
84
+ return fallback[0];
85
+ };
86
+ }
@@ -1,4 +1,5 @@
1
1
  import { openBrowser } from "./browser.js";
2
+ import { assignIdentities } from "./identity.js";
2
3
  import { collect } from "./collect.js";
3
4
  import { mkdir } from "node:fs/promises";
4
5
  import { join } from "node:path";
@@ -47,6 +48,7 @@ export async function scan(config, directory, phase) {
47
48
  const collected = await page.evaluate(collect, {
48
49
  maxElements: config.maxElements,
49
50
  ignore: config.ignore,
51
+ scopes: config.scopes,
50
52
  });
51
53
  if (!collected.elements.length)
52
54
  throw new Error(`No visible elements found at ${url} (${viewport.name}). Check your route and ignore selectors.`);
@@ -71,7 +73,13 @@ export async function scan(config, directory, phase) {
71
73
  fullPage: false,
72
74
  animations: "disabled",
73
75
  });
74
- result.snapshots.push({ route, viewport, ...collected, screenshot });
76
+ result.snapshots.push({
77
+ route,
78
+ viewport,
79
+ ...collected,
80
+ elements: assignIdentities(collected.elements),
81
+ screenshot,
82
+ });
75
83
  await context.close();
76
84
  }
77
85
  return result;
package/dist/types.d.ts CHANGED
@@ -14,13 +14,73 @@ export interface Config {
14
14
  maxElements: number;
15
15
  settleMs: number;
16
16
  ignore: string[];
17
+ scopes?: ScopeDefinition[];
18
+ }
19
+ export interface ScopeDefinition {
20
+ name: string;
21
+ selector: string;
22
+ variant?: string;
23
+ minOccurrences?: number;
24
+ parts?: Record<string, string>;
25
+ overrides?: {
26
+ part?: string;
27
+ category: Category;
28
+ property: string;
29
+ values: string[];
30
+ }[];
31
+ }
32
+ export interface ScopeMembership {
33
+ name: string;
34
+ variant?: string;
35
+ part: string;
36
+ instance: string;
37
+ }
38
+ export interface ScopedRule {
39
+ scope: string;
40
+ part: string;
41
+ category: Category;
42
+ property: string;
43
+ samples: number;
44
+ tokens: Token[];
45
+ source: "scope" | "override";
46
+ }
47
+ export interface RuleProvenance {
48
+ scope?: string;
49
+ variant?: string;
50
+ part?: string;
51
+ source: "scope" | "override" | "global";
52
+ reason?: "unscoped" | "missing-rule" | "insufficient-support" | "partial-baseline";
53
+ samples: number;
54
+ tokens: Token[];
17
55
  }
18
56
  export interface Observation {
19
57
  category: Category;
20
58
  property: string;
21
59
  value: string;
22
60
  }
61
+ export type IdentityConfidence = "high" | "medium" | "low";
62
+ export interface Bounds {
63
+ x: number;
64
+ y: number;
65
+ width: number;
66
+ height: number;
67
+ }
68
+ export interface Evidence {
69
+ viewport: Viewport;
70
+ current: {
71
+ screenshot: string;
72
+ bounds?: Bounds;
73
+ };
74
+ baseline?: {
75
+ screenshot: string;
76
+ bounds?: Bounds;
77
+ };
78
+ }
23
79
  export interface ElementRecord {
80
+ scope?: ScopeMembership;
81
+ identity?: string;
82
+ identityConfidence?: IdentityConfidence;
83
+ bounds?: Bounds;
24
84
  selector: string;
25
85
  tag: string;
26
86
  observations: Observation[];
@@ -50,11 +110,18 @@ export interface Token {
50
110
  count: number;
51
111
  }
52
112
  export type Contract = {
53
- version: 1;
113
+ version: 1 | 2;
114
+ scopes?: ScopeDefinition[];
115
+ rules?: ScopedRule[];
116
+ partial?: boolean;
54
117
  createdAt: string;
55
118
  minOccurrences: number;
56
119
  } & Record<Category, Token[]>;
57
120
  export interface Issue {
121
+ rule?: RuleProvenance;
122
+ identity?: string;
123
+ identityConfidence?: IdentityConfidence;
124
+ evidence?: Evidence[];
58
125
  id: string;
59
126
  category: Category | "layout";
60
127
  severity: Severity;