tether-ui 0.2.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 (53) hide show
  1. package/README.md +41 -0
  2. package/dist/cli/format.js +3 -2
  3. package/dist/cli/index.js +10 -5
  4. package/dist/cli/json.d.ts +2 -1
  5. package/dist/cli/json.js +1 -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 +69 -2
  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 +24 -6
  15. package/dist/scanner/collect.d.ts +3 -2
  16. package/dist/scanner/collect.js +46 -1
  17. package/dist/scanner/scan.js +1 -0
  18. package/dist/types.d.ts +44 -1
  19. package/dist/utils/config.js +3 -0
  20. package/package.json +4 -3
  21. package/report/out/404/index.html +1 -1
  22. package/report/out/404.html +1 -1
  23. package/report/out/__next.__PAGE__.txt +3 -3
  24. package/report/out/__next._full.txt +3 -3
  25. package/report/out/__next._tree.txt +2 -2
  26. package/report/out/_next/static/chunks/679-a159f64a448d339d.js +1 -0
  27. package/report/out/_next/static/css/7fbedb5502367c5e.css +1 -0
  28. package/report/out/_not-found/__next._full.txt +2 -2
  29. package/report/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  30. package/report/out/_not-found/__next._tree.txt +2 -2
  31. package/report/out/_not-found/index.html +1 -1
  32. package/report/out/_not-found/index.txt +2 -2
  33. package/report/out/contract/__next._full.txt +3 -3
  34. package/report/out/contract/__next._tree.txt +2 -2
  35. package/report/out/contract/__next.contract.__PAGE__.txt +3 -3
  36. package/report/out/contract/index.html +1 -1
  37. package/report/out/contract/index.txt +3 -3
  38. package/report/out/drift/__next._full.txt +3 -3
  39. package/report/out/drift/__next._tree.txt +2 -2
  40. package/report/out/drift/__next.drift.__PAGE__.txt +3 -3
  41. package/report/out/drift/index.html +1 -1
  42. package/report/out/drift/index.txt +3 -3
  43. package/report/out/index.html +1 -1
  44. package/report/out/index.txt +3 -3
  45. package/report/out/viewports/__next._full.txt +3 -3
  46. package/report/out/viewports/__next._tree.txt +2 -2
  47. package/report/out/viewports/__next.viewports.__PAGE__.txt +3 -3
  48. package/report/out/viewports/index.html +1 -1
  49. package/report/out/viewports/index.txt +3 -3
  50. package/report/out/_next/static/chunks/679-ea0b2616e7a83e6d.js +0 -1
  51. package/report/out/_next/static/css/5f0345b3d96e2143.css +0 -1
  52. /package/report/out/_next/static/{MCyQw-tRQ7-eGxwnJHR0Q → l8qlXIaCzWbRx2ZWOTp_i}/_buildManifest.js +0 -0
  53. /package/report/out/_next/static/{MCyQw-tRQ7-eGxwnJHR0Q → l8qlXIaCzWbRx2ZWOTp_i}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -162,6 +162,47 @@ Add intentional values to `contract.json` to accept them globally, or use `tethe
162
162
 
163
163
  For environments that cannot launch Chromium, `TETHER_BROWSER_ENDPOINT=http://127.0.0.1:9333` connects to a dedicated CDP browser instead. Use an isolated profile, not your everyday browser. tether creates and closes its own contexts and disconnects after each scan. Standard installations need no endpoint setting.
164
164
 
165
+ ## Component-aware contracts
166
+
167
+ Global inference remains the default. Opt in to named scopes when component variants need different rules:
168
+
169
+ ```ts
170
+ export default {
171
+ url: 'http://localhost:3000',
172
+ scopes: [
173
+ {
174
+ name: 'primary-button',
175
+ variant: 'primary',
176
+ selector: '[data-component="button"][data-variant="primary"]',
177
+ minOccurrences: 2,
178
+ parts: { label: '[data-part="label"]' }
179
+ },
180
+ {
181
+ name: 'card',
182
+ selector: '.card',
183
+ parts: { title: '.card-title' },
184
+ overrides: [
185
+ { part: 'title', category: 'fontSizes', property: 'font-size', values: ['24px'] }
186
+ ]
187
+ }
188
+ ]
189
+ };
190
+ ```
191
+
192
+ Run `npx tether-ui init --force` after intentionally adding or changing scopes. This replaces the baseline, so review the current UI first. Scope configuration is saved in the contract; subsequent checks reject configuration changes instead of silently changing the rules. Component-aware contracts are available in tether-ui v0.3.0 and later.
193
+
194
+ - **Membership:** the nearest matching component root wins. Within it, the nearest matching named part wins; a part applies to its descendants until a nested component starts. The root uses the reserved `root` part. Unnamed descendants use global tokens, rather than inheriting container padding and radius rules. Two scopes matching the same root, or two parts matching the same node, are errors. Selectors are explicit; generated class names do not identify component families automatically.
195
+ - **Support:** each scope/category/property/value counts distinct component instances per route. Multiple parts or viewport captures of one instance do not inflate its count. The scope threshold defaults to the global threshold with a minimum of two; configure `minOccurrences` per scope for smaller families. Multiple responsive values can qualify independently. Singleton conventions are never automatically promoted.
196
+ - **Precedence:** a supported scoped rule wins over the global pool. Missing or undersampled rules fall back to global tokens. Partial baselines disable inferred scoped rules, with the reason exposed in findings. Unchanged one-offs remain accepted only on the same matched element, property, and scope/part; they do not transfer to new instances or another component context.
197
+ - **Overrides:** explicit values are normalized and apply only to their scope, part, category, and property. They override inferred values and the unchanged-value exemption, so an intentional correction can expose a baseline inconsistency. Overrides can apply without samples; they are reviewed policy, not learned confidence. Existing layout checks remain independent.
198
+ - **Compatibility:** unscoped projects continue writing v1 contracts and retain their finding IDs and JSON behavior. Scoped projects write v2 contracts. New clients read both; old clients reject v2. Checks never migrate or rewrite baseline/contract files. Reports, agent output, and JSON explain the rule source, support counts, and fallback. The visual contract view includes component rules.
199
+
200
+ ### Accuracy benchmark
201
+
202
+ Run `npm run benchmark` from the development checkout. The [frozen corpus](tests/fixtures/scoped-benchmark.json) compares the legacy and scoped policies on 16 labeled scenarios across buttons, cards, status colors, and badges. Output includes TP/FP/FN/TN, precision, and recall per family and overall. Color/type families are reserved as held-out checks; no per-family thresholds are tuned.
203
+
204
+ Both policies use a global threshold of three. The scoped policy explicitly uses two independent component instances; this additional configuration is why legitimate new instances stop being false positives. Merely narrowing the global token pool cannot produce that improvement. On this synthetic corpus, false positives fall from 4 to 0 and detected drift rises from 4/8 to 8/8. [Raw results and benchmark limits](docs/scoped-benchmark.md) describe what these numbers establish; they are not an accuracy claim for arbitrary applications.
205
+
165
206
  ## CI
166
207
 
167
208
  The frontend **must already be running**. Commit reviewed `contract.json` and `baseline.json` to compare against the same contract on every pull request. Recreate the baseline on the same OS/browser/font environment as CI to avoid platform differences. Include its referenced baseline screenshots if you want baseline images in CI reports; comparison still works when those PNGs are absent.
@@ -1,3 +1,4 @@
1
+ import { ruleDescription } from "../contract/describe.js";
1
2
  const code = (value) => "`" + value.replace(/`/g, "\\`").replace(/[\r\n]/g, " ") + "`";
2
3
  export function agentMarkdown(issues) {
3
4
  const intro = "# tether visual linting\n\n" +
@@ -6,7 +7,7 @@ export function agentMarkdown(issues) {
6
7
  : "No visual-system deviations detected.\n");
7
8
  return (intro +
8
9
  issues
9
- .map((i, n) => `\n## ${n + 1}. ${i.title}\n\n- Severity: ${i.severity}\n- Selector: ${code(i.selector)}\n- Property: ${code(i.property)}\n- Current: ${code(i.current)}\n- Expected values: ${i.expected.length ? i.expected.map(code).join(", ") : "No established token; review against the baseline."}\n- Route: ${code(i.route)}\n- Viewports: ${i.viewports.map(code).join(", ")}\n`)
10
+ .map((i, n) => `\n## ${n + 1}. ${i.title}\n\n- Severity: ${i.severity}\n- Selector: ${code(i.selector)}\n- Property: ${code(i.property)}\n- Current: ${code(i.current)}\n- Expected values: ${i.expected.length ? i.expected.map(code).join(", ") : "No established token; review against the baseline."}\n${i.rule ? `- Rule: ${code(ruleDescription(i.rule))}\n` : ""}- Route: ${code(i.route)}\n- Viewports: ${i.viewports.map(code).join(", ")}\n`)
10
11
  .join("\n---\n"));
11
12
  }
12
13
  export function terminalReport(report, color = !!process.stdout.isTTY) {
@@ -24,7 +25,7 @@ export function terminalReport(report, color = !!process.stdout.isTTY) {
24
25
  : "No visual drift detected"),
25
26
  ];
26
27
  for (const issue of report.issues)
27
- lines.push("", `${issue.severity.toUpperCase()} ${issue.title}`, ` ${issue.current}`, dim(` Expected: ${issue.expected.join(" · ") || "No established token"}`), ` ${issue.selector}`, dim(` ${issue.route} · ${issue.viewports.join(", ")} · ${issue.property}`));
28
+ lines.push("", `${issue.severity.toUpperCase()} ${issue.title}`, ` ${issue.current}`, dim(` Expected: ${issue.expected.join(" · ") || "No established token"}`), ...(issue.rule ? [dim(` Rule: ${ruleDescription(issue.rule)}`)] : []), ` ${issue.selector}`, dim(` ${issue.route} · ${issue.viewports.join(", ")} · ${issue.property}`));
28
29
  lines.push("", `${report.issues.length} deviations found.`, dim("Run: tether report"), "");
29
30
  return lines.join("\n");
30
31
  }
package/dist/cli/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { readContract } from "../contract/read.js";
3
+ import { validateScopes } from "../contract/scopes.js";
2
4
  import { Command } from "commander";
3
5
  import { join, resolve } from "node:path";
4
6
  import { createInterface } from "node:readline/promises";
@@ -16,7 +18,7 @@ import { categories, } from "../types.js";
16
18
  const program = new Command()
17
19
  .name("tether")
18
20
  .description("Visual linting for AI-coded interfaces.")
19
- .version("0.2.0");
21
+ .version("0.3.0");
20
22
  program.option("--cwd <directory>", "Project directory", process.cwd());
21
23
  const cwd = () => resolve(program.opts().cwd);
22
24
  const directory = () => join(cwd(), ".tether");
@@ -65,7 +67,7 @@ program
65
67
  const config = await loadConfig(cwd(), options.url, options.config);
66
68
  console.log(`\ntether\n\nScanning ${config.url}…`);
67
69
  const baseline = await scan(config, directory(), `baseline-${randomUUID().slice(0, 8)}`);
68
- const contract = inferContract(baseline, config.minOccurrences);
70
+ const contract = inferContract(baseline, config.minOccurrences, config.scopes);
69
71
  // Save the exact scan configuration in the baseline so subsequent checks match it.
70
72
  await writeJson(join(directory(), "contract.json"), contract);
71
73
  await writeJson(join(directory(), "baseline.json"), {
@@ -78,7 +80,7 @@ program
78
80
  await writeFile(join(cwd(), "tether.config.ts"), `export default ${JSON.stringify(config, null, 2)};\n`);
79
81
  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`);
80
82
  if (report.issues.length)
81
- console.log(`Note: ${report.issues.length} existing layout problems are visible in tether report.`);
83
+ console.log(`Note: ${report.issues.length} existing visual deviations are visible in tether report.`);
82
84
  warnTruncated(report);
83
85
  });
84
86
  program
@@ -94,10 +96,10 @@ program
94
96
  if (!(await exists(join(directory(), "baseline.json"))))
95
97
  throw new Error("No baseline found. Run tether init first.");
96
98
  const baseline = await readJson(join(directory(), "baseline.json"));
97
- const contract = await readJson(join(directory(), "contract.json"));
99
+ const contract = readContract(await readJson(join(directory(), "contract.json")));
98
100
  if (baseline.version !== 1 ||
99
101
  !Array.isArray(baseline.snapshots) ||
100
- contract.version !== 1 ||
102
+ ![1, 2].includes(contract.version) ||
101
103
  categories.some((c) => !Array.isArray(contract[c]) ||
102
104
  contract[c].some((t) => typeof t.value !== "string")))
103
105
  throw new Error("Unsupported or invalid baseline / contract. Run tether init --force to recreate it.");
@@ -106,6 +108,9 @@ program
106
108
  !options.config &&
107
109
  !(await exists(join(cwd(), "tether.config.ts"))))
108
110
  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.");
109
114
  const coverage = (s) => `${s.route}|${s.viewport.name}|${s.viewport.width}|${s.viewport.height}`;
110
115
  const baselineCoverage = new Set(baseline.snapshots.map(coverage));
111
116
  const requested = config.routes.flatMap((route) => config.viewports.map((viewport) => coverage({ route, viewport })));
@@ -13,7 +13,6 @@ export declare function jsonReport(report: Report, baseline: Scan): {
13
13
  };
14
14
  };
15
15
  findings: {
16
- id: string;
17
16
  identity: string;
18
17
  identityConfidence: import("../types.js").IdentityConfidence;
19
18
  selector: string;
@@ -36,5 +35,7 @@ export declare function jsonReport(report: Report, baseline: Scan): {
36
35
  screenshotRef: string | null;
37
36
  };
38
37
  }[];
38
+ rule?: import("../types.js").RuleProvenance | undefined;
39
+ id: string;
39
40
  }[];
40
41
  };
package/dist/cli/json.js CHANGED
@@ -25,6 +25,7 @@ export function jsonReport(report, baseline) {
25
25
  },
26
26
  findings: report.issues.map((i) => ({
27
27
  id: i.id,
28
+ ...(i.rule ? { rule: i.rule } : {}),
28
29
  identity: i.identity ?? `selector:${i.selector}`,
29
30
  identityConfidence: i.identityConfidence ?? "low",
30
31
  selector: i.selector,
@@ -0,0 +1,2 @@
1
+ import type { RuleProvenance } from "../types.js";
2
+ export declare function ruleDescription(rule: RuleProvenance): string;
@@ -0,0 +1,6 @@
1
+ export function ruleDescription(rule) {
2
+ const context = rule.scope
3
+ ? `${rule.scope}${rule.variant ? ` (${rule.variant})` : ""}/${rule.part}`
4
+ : "unscoped";
5
+ return `${rule.source} · ${context}${rule.reason ? ` · ${rule.reason}` : ""} · ${rule.samples} component instances · tokens: ${rule.tokens.map((t) => `${t.value} (${t.count})`).join(", ") || "none"}`;
6
+ }
@@ -1,2 +1,2 @@
1
- import { type Contract, type Scan } from "../types.js";
2
- export declare function inferContract(scan: Scan, minOccurrences?: number): Contract;
1
+ import { type Contract, type Scan, type ScopeDefinition } from "../types.js";
2
+ export declare function inferContract(scan: Scan, minOccurrences?: number, scopes?: ScopeDefinition[]): Contract;
@@ -1,5 +1,6 @@
1
- import { categories } from "../types.js";
2
- export function inferContract(scan, minOccurrences = 2) {
1
+ import { validateScopes } from "./scopes.js";
2
+ import { categories, } from "../types.js";
3
+ export function inferContract(scan, minOccurrences = 2, scopes = []) {
3
4
  const contract = {
4
5
  version: 1,
5
6
  createdAt: scan.createdAt,
@@ -23,5 +24,71 @@ export function inferContract(scan, minOccurrences = 2) {
23
24
  // A small page still gets one representative token per observed category.
24
25
  contract[category] = recurring.length ? recurring : ranked.slice(0, 1);
25
26
  }
27
+ if (scopes.length) {
28
+ const definitions = validateScopes(scopes);
29
+ contract.version = 2;
30
+ contract.scopes = definitions;
31
+ contract.partial = scan.snapshots.some((s) => s.truncated);
32
+ const rules = new Map();
33
+ const key = (scope, part, category, property) => JSON.stringify([scope, part, category, property]);
34
+ for (const snapshot of scan.snapshots)
35
+ for (const element of snapshot.elements) {
36
+ const scope = element.scope;
37
+ if (!scope || !definitions.some((d) => d.name === scope.name))
38
+ continue;
39
+ const instance = JSON.stringify([snapshot.route, scope.instance]);
40
+ for (const o of element.observations) {
41
+ const k = key(scope.name, scope.part, o.category, o.property);
42
+ const entry = rules.get(k) ?? {
43
+ rule: {
44
+ scope: scope.name,
45
+ part: scope.part,
46
+ category: o.category,
47
+ property: o.property,
48
+ samples: 0,
49
+ tokens: [],
50
+ source: "scope",
51
+ },
52
+ samples: new Set(),
53
+ values: new Map(),
54
+ };
55
+ entry.samples.add(instance);
56
+ const values = entry.values.get(o.value) ?? new Set();
57
+ values.add(instance);
58
+ entry.values.set(o.value, values);
59
+ rules.set(k, entry);
60
+ }
61
+ }
62
+ for (const scope of definitions)
63
+ for (const override of scope.overrides ?? []) {
64
+ const k = key(scope.name, override.part ?? "root", override.category, override.property);
65
+ const old = rules.get(k);
66
+ rules.set(k, {
67
+ rule: {
68
+ scope: scope.name,
69
+ part: override.part ?? "root",
70
+ category: override.category,
71
+ property: override.property,
72
+ samples: 0,
73
+ tokens: [],
74
+ source: "override",
75
+ },
76
+ samples: old?.samples ?? new Set(),
77
+ values: new Map(override.values.map((v) => [
78
+ v,
79
+ old?.values.get(v) ?? new Set(),
80
+ ])),
81
+ });
82
+ }
83
+ contract.rules = [...rules.entries()]
84
+ .sort(([a], [b]) => a.localeCompare(b))
85
+ .map(([, entry]) => ({
86
+ ...entry.rule,
87
+ samples: entry.samples.size,
88
+ tokens: [...entry.values]
89
+ .map(([value, instances]) => ({ value, count: instances.size }))
90
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)),
91
+ }));
92
+ }
26
93
  return contract;
27
94
  }
@@ -0,0 +1,3 @@
1
+ import { type Contract } from "../types.js";
2
+ /** Validate without rewriting legacy data or changing its ordering. */
3
+ export declare function readContract(input: unknown): Contract;
@@ -0,0 +1,67 @@
1
+ import { categories } from "../types.js";
2
+ import { validateScopes, validProperty } from "./scopes.js";
3
+ /** Validate without rewriting legacy data or changing its ordering. */
4
+ export function readContract(input) {
5
+ const c = input;
6
+ const fail = () => {
7
+ throw new Error("Unsupported or invalid contract. Supported versions: 1 (global), 2 (scoped). Restore a valid contract or run tether init --force.");
8
+ };
9
+ const tokens = (t) => Array.isArray(t) &&
10
+ t.every((v) => v &&
11
+ typeof v.value === "string" &&
12
+ Number.isInteger(v.count) &&
13
+ v.count >= 0);
14
+ if (!c ||
15
+ ![1, 2].includes(c.version) ||
16
+ !Number.isInteger(c.minOccurrences) ||
17
+ c.minOccurrences < 1 ||
18
+ c.minOccurrences > 1000 ||
19
+ categories.some((k) => !tokens(c[k])))
20
+ fail();
21
+ if (c.version === 2) {
22
+ const scopes = validateScopes(c.scopes);
23
+ if (!scopes.length ||
24
+ !Array.isArray(c.rules) ||
25
+ typeof c.partial !== "boolean")
26
+ fail();
27
+ const keys = new Set();
28
+ for (const rule of c.rules) {
29
+ if (!rule || typeof rule !== "object")
30
+ fail();
31
+ const scope = scopes.find((s) => s.name === rule.scope);
32
+ if (!scope ||
33
+ (rule.part !== "root" &&
34
+ !Object.hasOwn(scope.parts ?? {}, rule.part)) ||
35
+ !categories.includes(rule.category) ||
36
+ !validProperty(rule.category, rule.property) ||
37
+ !["scope", "override"].includes(rule.source) ||
38
+ !Number.isInteger(rule.samples) ||
39
+ rule.samples < 0 ||
40
+ !tokens(rule.tokens) ||
41
+ rule.tokens.some((t) => t.count > rule.samples))
42
+ fail();
43
+ const key = JSON.stringify([
44
+ rule.scope,
45
+ rule.part,
46
+ rule.category,
47
+ rule.property,
48
+ ]);
49
+ if (keys.has(key))
50
+ fail();
51
+ keys.add(key);
52
+ const override = scope.overrides?.find((o) => o.part === rule.part &&
53
+ o.category === rule.category &&
54
+ o.property === rule.property);
55
+ if ((rule.source === "override") !== !!override ||
56
+ (override &&
57
+ JSON.stringify([...override.values].sort()) !==
58
+ JSON.stringify(rule.tokens.map((t) => t.value).sort())))
59
+ fail();
60
+ }
61
+ for (const scope of scopes)
62
+ for (const o of scope.overrides ?? [])
63
+ if (!keys.has(JSON.stringify([scope.name, o.part, o.category, o.property])))
64
+ fail();
65
+ }
66
+ return c;
67
+ }
@@ -0,0 +1,5 @@
1
+ import { type ScopeDefinition, type Contract, type ElementRecord, type Observation, type RuleProvenance } from "../types.js";
2
+ export declare function validProperty(category: string, property: unknown): property is string;
3
+ /** Canonical, serializable declarations. CSS syntax is checked in the browser. */
4
+ export declare function validateScopes(input: unknown): ScopeDefinition[];
5
+ export declare function resolveRule(contract: Contract, element: ElementRecord, observation: Observation): RuleProvenance | undefined;
@@ -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,3 +1,4 @@
1
+ import { resolveRule } from "../contract/scopes.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { baselineMatcher } from "../scanner/identity.js";
3
4
  import { findingEvidence } from "./evidence.js";
@@ -50,7 +51,9 @@ export function compare(contract, baseline, current) {
50
51
  issue.identity ?? issue.selector,
51
52
  issue.current,
52
53
  issue.title,
53
- ].join("\0");
54
+ ]
55
+ .concat(issue.rule ? [issue.property, JSON.stringify(issue.rule)] : [])
56
+ .join("\0");
54
57
  const previous = issues.get(key);
55
58
  if (previous) {
56
59
  if (!previous.viewports.includes(viewport))
@@ -77,14 +80,20 @@ export function compare(contract, baseline, current) {
77
80
  for (const element of snapshot.elements) {
78
81
  const prior = match(element);
79
82
  for (const o of element.observations) {
80
- 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);
81
85
  if (expected.some((v) => equivalent(o.category, v, o.value)))
82
86
  continue;
83
- if (prior?.observations.some((p) => p.category === o.category &&
84
- p.property === o.property &&
85
- equivalent(o.category, p.value, 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)))
86
94
  continue;
87
95
  push({
96
+ ...(rule ? { rule } : {}),
88
97
  category: o.category,
89
98
  severity: severityFor(o.category, o.value, expected),
90
99
  title: labels[o.category],
@@ -125,11 +134,20 @@ export function compare(contract, baseline, current) {
125
134
  }
126
135
  }
127
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
+ }
128
142
  return [...issues.values()].sort((a, b) => rank[a.severity] - rank[b.severity] ||
129
143
  a.category.localeCompare(b.category) ||
130
144
  a.route.localeCompare(b.route) ||
131
145
  (a.identity ?? a.selector).localeCompare(b.identity ?? b.selector) ||
132
- a.current.localeCompare(b.current));
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));
133
151
  }
134
152
  export function consistencyScore(issues) {
135
153
  return Math.max(0, 100 -
@@ -1,9 +1,10 @@
1
1
  import type { CollectedElement } from "./identity.js";
2
- import type { LayoutProblem } from "../types.js";
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, }: {
4
+ export declare function collect({ maxElements, ignore, scopes, }: {
5
5
  maxElements: number;
6
6
  ignore: string[];
7
+ scopes?: ScopeDefinition[];
7
8
  }): {
8
9
  elements: CollectedElement[];
9
10
  layout: LayoutProblem[];
@@ -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))
@@ -160,7 +203,9 @@ export function collect({ maxElements, ignore, }) {
160
203
  .split(/\s+/)
161
204
  .map((id) => document.getElementById(id)?.textContent ?? "")
162
205
  .join(" ");
206
+ const scope = scopeFor(el);
163
207
  elements.push({
208
+ ...(scope ? { scope } : {}),
164
209
  selector,
165
210
  tag: el.tagName.toLowerCase(),
166
211
  observations,
@@ -48,6 +48,7 @@ export async function scan(config, directory, phase) {
48
48
  const collected = await page.evaluate(collect, {
49
49
  maxElements: config.maxElements,
50
50
  ignore: config.ignore,
51
+ scopes: config.scopes,
51
52
  });
52
53
  if (!collected.elements.length)
53
54
  throw new Error(`No visible elements found at ${url} (${viewport.name}). Check your route and ignore selectors.`);