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
@@ -1,90 +1,155 @@
1
1
  import { openBrowser } from "./browser.js";
2
2
  import { assignIdentities } from "./identity.js";
3
3
  import { collect } from "./collect.js";
4
- import { mkdir } from "node:fs/promises";
4
+ import { mkdir, rm, readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { createHash } from "node:crypto";
7
7
  import { normalizeValue } from "../utils/normalize.js";
8
+ import { captures, safeUrl } from "../utils/scenarios.js";
8
9
  import { targetUrl } from "../utils/config.js";
9
10
  export async function scan(config, directory, phase) {
10
- await mkdir(join(directory, "screenshots", phase), { recursive: true });
11
+ let storageState;
12
+ if (config.storageState) {
13
+ try {
14
+ storageState = JSON.parse(await readFile(config.storageState, "utf8"));
15
+ if (!storageState ||
16
+ typeof storageState !== "object" ||
17
+ Array.isArray(storageState))
18
+ throw new Error();
19
+ }
20
+ catch {
21
+ const first = captures(config)[0];
22
+ throw new Error(`Scan failed: route ${safeUrl(first.route)}, state ${first.scenario.id}, viewport ${config.viewports[0].name}, step context. Could not read a valid storage-state file.`);
23
+ }
24
+ }
11
25
  const browser = await openBrowser();
26
+ let complete = false;
12
27
  const result = {
13
28
  version: 1,
14
29
  createdAt: new Date().toISOString(),
15
- url: config.url,
30
+ url: safeUrl(config.url),
16
31
  snapshots: [],
17
32
  };
18
33
  try {
19
- for (const route of config.routes)
34
+ await mkdir(join(directory, "screenshots", phase), { recursive: true });
35
+ for (const { route, scenario } of captures(config))
20
36
  for (const viewport of config.viewports) {
21
- const context = await browser.newContext({
22
- viewport: { width: viewport.width, height: viewport.height },
23
- deviceScaleFactor: 1,
24
- colorScheme: "light",
25
- reducedMotion: "reduce",
26
- });
27
- const page = await context.newPage();
28
- const url = targetUrl(config.url, route);
37
+ let context;
38
+ let step = "context";
39
+ let timer;
29
40
  try {
30
- const response = await page.goto(url, {
31
- waitUntil: "load",
32
- timeout: 30000,
33
- });
34
- if (response && response.status() >= 400)
35
- throw new Error(`HTTP ${response.status()}`);
41
+ await Promise.race([
42
+ (async () => {
43
+ context = await browser.newContext({
44
+ ...(storageState ? { storageState } : {}),
45
+ viewport: { width: viewport.width, height: viewport.height },
46
+ deviceScaleFactor: 1,
47
+ colorScheme: scenario.colorScheme ?? "light",
48
+ reducedMotion: "reduce",
49
+ });
50
+ const page = await context.newPage();
51
+ page.setDefaultTimeout(Math.min(10000, scenario.timeoutMs ?? 30000));
52
+ page.setDefaultNavigationTimeout(Math.min(30000, scenario.timeoutMs ?? 30000));
53
+ step = "setup";
54
+ await scenario.setup?.(page, context);
55
+ step = "navigation";
56
+ const url = targetUrl(config.url, route);
57
+ try {
58
+ const response = await page.goto(url, {
59
+ waitUntil: "load",
60
+ timeout: 30000,
61
+ });
62
+ if (response && response.status() >= 400)
63
+ throw new Error(`HTTP ${response.status()}`);
64
+ }
65
+ catch {
66
+ throw new Error("Navigation failed");
67
+ }
68
+ step = "interaction";
69
+ await scenario.interact?.(page);
70
+ step = "readiness";
71
+ await scenario.ready(page);
72
+ step = "capture";
73
+ await page.addStyleTag({
74
+ content: "*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }",
75
+ });
76
+ await page.evaluate(() => Promise.race([
77
+ document.fonts.ready,
78
+ new Promise((resolve) => setTimeout(resolve, 5000)),
79
+ ]));
80
+ await page.waitForTimeout(config.settleMs);
81
+ const collected = await page.evaluate(collect, {
82
+ maxElements: config.maxElements,
83
+ ignore: config.ignore,
84
+ scopes: config.scopes,
85
+ sensitiveSelectors: config.sensitiveSelectors,
86
+ });
87
+ if (!collected.elements.length)
88
+ throw new Error("No visible elements found");
89
+ for (const element of collected.elements) {
90
+ element.observations = [
91
+ ...new Map(element.observations.map((o) => {
92
+ const normalized = {
93
+ ...o,
94
+ value: normalizeValue(o.property, o.value),
95
+ };
96
+ return [
97
+ `${o.category}\0${o.property}\0${normalized.value}`,
98
+ normalized,
99
+ ];
100
+ })).values(),
101
+ ].filter((o) => o.value !== "transparent" && o.value !== "0px");
102
+ }
103
+ const filename = `${createHash("sha256")
104
+ .update(scenario.id === "default"
105
+ ? route
106
+ : JSON.stringify([route, scenario.id]))
107
+ .digest("hex")
108
+ .slice(0, 12)}-${viewport.name}.png`;
109
+ const screenshot = `screenshots/${phase}/${filename}`;
110
+ await page.screenshot({
111
+ path: join(directory, screenshot),
112
+ fullPage: false,
113
+ animations: "disabled",
114
+ mask: (config.sensitiveSelectors ?? []).map((selector) => page.locator(selector)),
115
+ maskColor: "#000000",
116
+ });
117
+ result.snapshots.push({
118
+ route: safeUrl(route),
119
+ ...(scenario.id !== "default" ? { state: scenario.id } : {}),
120
+ ...(scenario.colorScheme === "dark"
121
+ ? { colorScheme: "dark" }
122
+ : {}),
123
+ viewport,
124
+ ...collected,
125
+ elements: assignIdentities(collected.elements),
126
+ screenshot,
127
+ });
128
+ })(),
129
+ new Promise((_, reject) => {
130
+ timer = setTimeout(() => reject(new Error("timeout")), scenario.timeoutMs ?? 30000);
131
+ }),
132
+ ]);
36
133
  }
37
134
  catch {
38
- throw new Error(`tether couldn't reach ${url}.\nStart your application, check the route, and try again.`);
135
+ // Never forward Playwright errors: they can contain locator text, cookies or submitted values.
136
+ throw new Error(`Scan failed: route ${safeUrl(route)}, state ${scenario.id}, viewport ${viewport.name}, step ${step}. Check setup/readiness and timeoutMs; previous artifacts were preserved.`);
39
137
  }
40
- await page.addStyleTag({
41
- content: "*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }",
42
- });
43
- await page.evaluate(() => Promise.race([
44
- document.fonts.ready,
45
- new Promise((resolve) => setTimeout(resolve, 5000)),
46
- ]));
47
- await page.waitForTimeout(config.settleMs);
48
- const collected = await page.evaluate(collect, {
49
- maxElements: config.maxElements,
50
- ignore: config.ignore,
51
- scopes: config.scopes,
52
- });
53
- if (!collected.elements.length)
54
- throw new Error(`No visible elements found at ${url} (${viewport.name}). Check your route and ignore selectors.`);
55
- for (const element of collected.elements) {
56
- element.observations = [
57
- ...new Map(element.observations.map((o) => {
58
- const normalized = {
59
- ...o,
60
- value: normalizeValue(o.property, o.value),
61
- };
62
- return [
63
- `${o.category}\0${o.property}\0${normalized.value}`,
64
- normalized,
65
- ];
66
- })).values(),
67
- ].filter((o) => o.value !== "transparent" && o.value !== "0px");
138
+ finally {
139
+ if (timer)
140
+ clearTimeout(timer);
141
+ await context?.close().catch(() => { });
68
142
  }
69
- const filename = `${createHash("sha256").update(route).digest("hex").slice(0, 12)}-${viewport.name}.png`;
70
- const screenshot = `screenshots/${phase}/${filename}`;
71
- await page.screenshot({
72
- path: join(directory, screenshot),
73
- fullPage: false,
74
- animations: "disabled",
75
- });
76
- result.snapshots.push({
77
- route,
78
- viewport,
79
- ...collected,
80
- elements: assignIdentities(collected.elements),
81
- screenshot,
82
- });
83
- await context.close();
84
143
  }
144
+ complete = true;
85
145
  return result;
86
146
  }
87
147
  finally {
88
148
  await browser.close();
149
+ if (!complete)
150
+ await rm(join(directory, "screenshots", phase), {
151
+ recursive: true,
152
+ force: true,
153
+ });
89
154
  }
90
155
  }
package/dist/types.d.ts CHANGED
@@ -6,7 +6,19 @@ export interface Viewport {
6
6
  width: number;
7
7
  height: number;
8
8
  }
9
+ export interface Scenario {
10
+ id: string;
11
+ routes: string[];
12
+ colorScheme?: "light" | "dark";
13
+ timeoutMs?: number;
14
+ setup?: (page: import("playwright").Page, context: import("playwright").BrowserContext) => Promise<void>;
15
+ interact?: (page: import("playwright").Page) => Promise<void>;
16
+ ready: (page: import("playwright").Page) => Promise<void>;
17
+ }
9
18
  export interface Config {
19
+ scenarios?: Scenario[];
20
+ storageState?: string;
21
+ sensitiveSelectors?: string[];
10
22
  url: string;
11
23
  routes: string[];
12
24
  viewports: Viewport[];
@@ -66,6 +78,7 @@ export interface Bounds {
66
78
  height: number;
67
79
  }
68
80
  export interface Evidence {
81
+ state?: string;
69
82
  viewport: Viewport;
70
83
  current: {
71
84
  screenshot: string;
@@ -92,6 +105,8 @@ export interface LayoutProblem {
92
105
  severity: Severity;
93
106
  }
94
107
  export interface Snapshot {
108
+ state?: string;
109
+ colorScheme?: "light" | "dark";
95
110
  route: string;
96
111
  viewport: Viewport;
97
112
  elements: ElementRecord[];
@@ -117,7 +132,49 @@ export type Contract = {
117
132
  createdAt: string;
118
133
  minOccurrences: number;
119
134
  } & Record<Category, Token[]>;
135
+ export interface Approval {
136
+ id: string;
137
+ reason: string;
138
+ route: string;
139
+ state: string;
140
+ target: {
141
+ kind: "identity";
142
+ value: string;
143
+ } | {
144
+ kind: "selector";
145
+ value: string;
146
+ identity: string | null;
147
+ };
148
+ category: Category | "layout";
149
+ property: string;
150
+ current: string;
151
+ viewport: Viewport;
152
+ context?: {
153
+ scope: string;
154
+ part: string;
155
+ };
156
+ }
157
+ export interface ApprovalFile {
158
+ version: 1;
159
+ approvals: Approval[];
160
+ }
161
+ export interface ApprovalSummary {
162
+ approved: Issue[];
163
+ stale: Approval[];
164
+ }
165
+ export interface FindingOccurrence {
166
+ issue: Omit<Issue, "id" | "viewports">;
167
+ viewport: Viewport;
168
+ state: string;
169
+ identityUnique: boolean;
170
+ selectorUnique: boolean;
171
+ }
120
172
  export interface Issue {
173
+ state?: string;
174
+ approval?: {
175
+ id: string;
176
+ reason: string;
177
+ };
121
178
  rule?: RuleProvenance;
122
179
  identity?: string;
123
180
  identityConfidence?: IdentityConfidence;
@@ -134,7 +191,8 @@ export interface Issue {
134
191
  viewports: string[];
135
192
  }
136
193
  export interface Report {
137
- version: 1;
194
+ version: 1 | 2;
195
+ approvals?: ApprovalSummary;
138
196
  baselineAt: string;
139
197
  scan: Scan;
140
198
  contract: Contract;
@@ -1,6 +1,7 @@
1
1
  import { validateScopes } from "../contract/scopes.js";
2
2
  import { access } from "node:fs/promises";
3
- import { resolve } from "node:path";
3
+ import { validateScenarios, safeUrl } from "./scenarios.js";
4
+ import { resolve, dirname, sep } from "node:path";
4
5
  import { createJiti } from "jiti";
5
6
  export const defaults = {
6
7
  url: "http://localhost:3000",
@@ -36,7 +37,9 @@ export async function loadConfig(cwd, url, file) {
36
37
  throw new Error("Configuration must export an object.");
37
38
  const config = { ...defaults, ...input, ...(url ? { url } : {}) };
38
39
  try {
39
- if (!["http:", "https:"].includes(new URL(config.url).protocol))
40
+ if (!["http:", "https:"].includes(new URL(config.url).protocol) ||
41
+ new URL(config.url).username ||
42
+ new URL(config.url).password)
40
43
  throw 0;
41
44
  }
42
45
  catch {
@@ -76,6 +79,16 @@ export async function loadConfig(cwd, url, file) {
76
79
  if (config.scopes !== undefined)
77
80
  config.scopes = validateScopes(config.scopes);
78
81
  config.routes = [...new Set(config.routes)];
82
+ validateScenarios(config);
83
+ if (config.storageState) {
84
+ config.storageState = resolve(dirname(path), config.storageState);
85
+ if (config.storageState.startsWith(resolve(cwd, ".tether") + sep))
86
+ throw new Error("Keep storageState outside .tether; use an ignored .auth directory.");
87
+ if (config.routes.some((r) => !config.scenarios?.some((s) => s.routes.includes(r))))
88
+ throw new Error("Every authenticated route needs a scenario with an explicit ready assertion.");
89
+ }
90
+ if (new Set(config.routes.map((r) => safeUrl(r))).size !== config.routes.length)
91
+ throw new Error("Routes must remain distinct after query-value redaction; use named scenarios for variants.");
79
92
  return config;
80
93
  }
81
94
  export function targetUrl(base, route) {
@@ -0,0 +1,3 @@
1
+ import type { Config, Contract, Scan } from "../types.js";
2
+ export declare function coverage(scan: Pick<Scan, "snapshots">): string[];
3
+ export declare function validateCoverage(config: Config, contract: Contract, baseline: Scan): void;
@@ -0,0 +1,28 @@
1
+ import { captures, safeUrl } from "./scenarios.js";
2
+ import { validateScopes } from "../contract/scopes.js";
3
+ export function coverage(scan) {
4
+ return scan.snapshots
5
+ .map((s) => JSON.stringify([
6
+ safeUrl(s.route),
7
+ s.viewport.name,
8
+ s.viewport.width,
9
+ s.viewport.height,
10
+ s.state ?? "default",
11
+ s.colorScheme ?? "light",
12
+ ]))
13
+ .sort();
14
+ }
15
+ export function validateCoverage(config, contract, baseline) {
16
+ if (JSON.stringify(validateScopes(config.scopes ?? [])) !==
17
+ JSON.stringify(validateScopes(contract.scopes ?? [])))
18
+ throw new Error("Component scopes or overrides changed since the baseline. Run tether init --force to establish the new contract.");
19
+ const requested = captures(config).flatMap(({ route, scenario }) => config.viewports.map((viewport) => ({
20
+ route: safeUrl(route),
21
+ viewport,
22
+ state: scenario.id,
23
+ colorScheme: scenario.colorScheme ?? "light",
24
+ })));
25
+ if (JSON.stringify(coverage({ snapshots: requested })) !==
26
+ JSON.stringify(coverage(baseline)))
27
+ throw new Error("Routes, states, color schemes or viewports changed since the baseline. Run tether init --force to establish the new coverage.");
28
+ }
@@ -0,0 +1,24 @@
1
+ import type { Config, Scenario } from "../types.js";
2
+ /** Query values and fragments are runtime-only, regardless of parameter spelling. */
3
+ export declare function safeUrl(value: string): string;
4
+ export declare function captures(config: Config): {
5
+ route: string;
6
+ scenario: Scenario;
7
+ }[];
8
+ export declare function validateScenarios(config: Config): void;
9
+ /** Deliberate allowlist: no hook sources, authentication paths or arbitrary config fields. */
10
+ export declare function persistedConfig(c: Config): {
11
+ states?: {
12
+ route: string;
13
+ id: string;
14
+ colorScheme: "light" | "dark";
15
+ }[] | undefined;
16
+ scopes?: import("../types.js").ScopeDefinition[] | undefined;
17
+ url: string;
18
+ routes: string[];
19
+ viewports: import("../types.js").Viewport[];
20
+ minOccurrences: number;
21
+ maxElements: number;
22
+ settleMs: number;
23
+ ignore: string[];
24
+ };
@@ -0,0 +1,82 @@
1
+ /** Query values and fragments are runtime-only, regardless of parameter spelling. */
2
+ export function safeUrl(value) {
3
+ const absolute = /^https?:\/\//.test(value);
4
+ const u = new URL(value, "http://tether.local");
5
+ u.username = "";
6
+ u.password = "";
7
+ u.hash = "";
8
+ for (const key of [...u.searchParams.keys()])
9
+ u.searchParams.set(key, "[redacted]");
10
+ return absolute
11
+ ? u.toString().replace(/\/$/, value.endsWith("/") ? "/" : "")
12
+ : u.pathname + u.search;
13
+ }
14
+ export function captures(config) {
15
+ return config.routes.flatMap((route) => {
16
+ const selected = config.scenarios?.filter((s) => s.routes.includes(route));
17
+ return (selected?.length
18
+ ? selected
19
+ : [{ id: "default", routes: [route], ready: async () => { } }]).map((scenario) => ({ route, scenario }));
20
+ });
21
+ }
22
+ export function validateScenarios(config) {
23
+ if (config.storageState !== undefined &&
24
+ (typeof config.storageState !== "string" || !config.storageState.trim()))
25
+ throw new Error("storageState must be a local Playwright storage-state file path.");
26
+ if (config.sensitiveSelectors !== undefined &&
27
+ (!Array.isArray(config.sensitiveSelectors) ||
28
+ config.sensitiveSelectors.some((s) => typeof s !== "string" || !s.trim())))
29
+ throw new Error("sensitiveSelectors must be a list of CSS selectors.");
30
+ if (config.scenarios === undefined)
31
+ return;
32
+ if (!Array.isArray(config.scenarios) || !config.scenarios.length)
33
+ throw new Error("scenarios must be a nonempty list.");
34
+ const ids = new Set();
35
+ for (const s of config.scenarios) {
36
+ if (!s ||
37
+ typeof s.id !== "string" ||
38
+ !/^[a-zA-Z0-9_-]{1,64}$/.test(s.id) ||
39
+ ids.has(s.id))
40
+ throw new Error("Scenario IDs must be unique, 1–64 letters, digits, underscores or hyphens.");
41
+ ids.add(s.id);
42
+ if (!Array.isArray(s.routes) ||
43
+ !s.routes.length ||
44
+ s.routes.some((r) => !config.routes.includes(r)) ||
45
+ new Set(s.routes).size !== s.routes.length)
46
+ throw new Error(`Scenario ${s.id} must select unique configured routes.`);
47
+ if (typeof s.ready !== "function" ||
48
+ (s.setup !== undefined && typeof s.setup !== "function") ||
49
+ (s.interact !== undefined && typeof s.interact !== "function"))
50
+ throw new Error(`Scenario ${s.id} needs a ready function; setup/interact must be functions.`);
51
+ if (s.colorScheme !== undefined &&
52
+ !["light", "dark"].includes(s.colorScheme))
53
+ throw new Error(`Invalid color scheme in scenario ${s.id}.`);
54
+ if (s.timeoutMs !== undefined &&
55
+ (!Number.isInteger(s.timeoutMs) ||
56
+ s.timeoutMs < 100 ||
57
+ s.timeoutMs > 120000))
58
+ throw new Error(`Scenario ${s.id} timeoutMs must be 100–120000.`);
59
+ }
60
+ }
61
+ /** Deliberate allowlist: no hook sources, authentication paths or arbitrary config fields. */
62
+ export function persistedConfig(c) {
63
+ return {
64
+ url: safeUrl(c.url),
65
+ routes: c.routes.map(safeUrl),
66
+ viewports: c.viewports,
67
+ minOccurrences: c.minOccurrences,
68
+ maxElements: c.maxElements,
69
+ settleMs: c.settleMs,
70
+ ignore: c.ignore,
71
+ ...(c.scopes ? { scopes: c.scopes } : {}),
72
+ ...(c.scenarios
73
+ ? {
74
+ states: captures(c).map(({ route, scenario }) => ({
75
+ route: safeUrl(route),
76
+ id: scenario.id,
77
+ colorScheme: scenario.colorScheme ?? "light",
78
+ })),
79
+ }
80
+ : {}),
81
+ };
82
+ }
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
1
+ import { readFile, writeFile, rename, mkdir, rm } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  export async function readJson(path) {
@@ -12,6 +12,11 @@ export async function readJson(path) {
12
12
  export async function writeJson(path, data) {
13
13
  await mkdir(dirname(path), { recursive: true });
14
14
  const temporary = `${path}.${randomUUID()}.tmp`;
15
- await writeFile(temporary, JSON.stringify(data, null, 2) + "\n");
16
- await rename(temporary, path);
15
+ try {
16
+ await writeFile(temporary, JSON.stringify(data, null, 2) + "\n");
17
+ await rename(temporary, path);
18
+ }
19
+ finally {
20
+ await rm(temporary, { force: true });
21
+ }
17
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tether-ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Visual linting for AI-coded interfaces. Learn the existing design language and detect frontend drift.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/7fbedb5502367c5e.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-21c0bda91597affb.js"/><script src="/_next/static/chunks/c7879cf7-33323d49ba6da96a.js" async=""></script><script src="/_next/static/chunks/158-65f5491737ca4c2e.js" async=""></script><script src="/_next/static/chunks/main-app-e3082ce30f616b80.js" async=""></script><meta name="robots" content="noindex"/><title>tether — Visual linting</title><meta name="description" content="tether learns your UI’s existing design language, then catches drift as humans and agents make changes."/><link rel="icon" href="/knot.svg"/><title>404: This page could not be found.</title><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-21c0bda91597affb.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[1133,[],\"\"]\n3:I[4297,[],\"\"]\n4:I[1872,[],\"OutletBoundary\"]\n5:\"$Sreact.suspense\"\n8:I[1872,[],\"ViewportBoundary\"]\na:I[1872,[],\"MetadataBoundary\"]\nc:I[7567,[],\"default\",1]\n:HL[\"/_next/static/css/7fbedb5502367c5e.css\",\"style\"]\n7:X\n0:{\"P\":null,\"c\":[\"\",\"_not-found\",\"\"],\"q\":\"\",\"i\":false,\"f\":[[[\"\",{\"children\":[\"_not-found\",{\"children\":[\"__PAGE__\",{},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4624],[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/7fbedb5502367c5e.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null,[\"$\",\"$L4\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.MetadataOutlet\",\"children\":\"$@6\"}]}]]}],{},null,false,null]},null,false,\"$7\"]},null,false,null],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$L8\",null,{\"children\":\"$L9\"}],[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$La\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.Metadata\",\"children\":\"$Lb\"}]}]}],null]}],false]],\"m\":\"$undefined\",\"G\":[\"$c\",[]],\"S\":true,\"h\":null,\"r\":\"$undefined\",\"s\":\"$undefined\",\"a\":\"$undefined\",\"l\":\"$undefined\",\"p\":\"$undefined\",\"d\":\"$undefined\",\"b\":\"l8qlXIaCzWbRx2ZWOTp_i\"}\n7:C\n9:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nd:I[2569,[],\"IconMark\"]\n6:null\nb:[[\"$\",\"title\",\"0\",{\"children\":\"tether — Visual linting\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"tether learns your UI’s existing design language, then catches drift as humans and agents make changes.\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/knot.svg\"}],[\"$\",\"$Ld\",\"3\",{}]]\n"])</script></body></html>
1
+ <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/b754cdc60c7b43bb.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-21c0bda91597affb.js"/><script src="/_next/static/chunks/c7879cf7-33323d49ba6da96a.js" async=""></script><script src="/_next/static/chunks/158-65f5491737ca4c2e.js" async=""></script><script src="/_next/static/chunks/main-app-e7e077df7256067f.js" async=""></script><meta name="robots" content="noindex"/><title>tether — Visual linting</title><meta name="description" content="tether learns your UI’s existing design language, then catches drift as humans and agents make changes."/><link rel="icon" href="/knot.svg"/><title>404: This page could not be found.</title><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-21c0bda91597affb.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[1133,[],\"\"]\n3:I[4297,[],\"\"]\n4:I[1872,[],\"OutletBoundary\"]\n5:\"$Sreact.suspense\"\n8:I[1872,[],\"ViewportBoundary\"]\na:I[1872,[],\"MetadataBoundary\"]\nc:I[7567,[],\"default\",1]\n:HL[\"/_next/static/css/b754cdc60c7b43bb.css\",\"style\"]\n7:X\n0:{\"P\":null,\"c\":[\"\",\"_not-found\",\"\"],\"q\":\"\",\"i\":false,\"f\":[[[\"\",{\"children\":[\"_not-found\",{\"children\":[\"__PAGE__\",{},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4624],[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/b754cdc60c7b43bb.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null,[\"$\",\"$L4\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.MetadataOutlet\",\"children\":\"$@6\"}]}]]}],{},null,false,null]},null,false,\"$7\"]},null,false,null],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$L8\",null,{\"children\":\"$L9\"}],[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$La\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.Metadata\",\"children\":\"$Lb\"}]}]}],null]}],false]],\"m\":\"$undefined\",\"G\":[\"$c\",[]],\"S\":true,\"h\":null,\"r\":\"$undefined\",\"s\":\"$undefined\",\"a\":\"$undefined\",\"l\":\"$undefined\",\"p\":\"$undefined\",\"d\":\"$undefined\",\"b\":\"TBRjO9Fdl2ToZdlLQ7gIf\"}\n7:C\n9:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nd:I[2569,[],\"IconMark\"]\n6:null\nb:[[\"$\",\"title\",\"0\",{\"children\":\"tether — Visual linting\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"tether learns your UI’s existing design language, then catches drift as humans and agents make changes.\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/knot.svg\"}],[\"$\",\"$Ld\",\"3\",{}]]\n"])</script></body></html>
@@ -1 +1 @@
1
- <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/7fbedb5502367c5e.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-21c0bda91597affb.js"/><script src="/_next/static/chunks/c7879cf7-33323d49ba6da96a.js" async=""></script><script src="/_next/static/chunks/158-65f5491737ca4c2e.js" async=""></script><script src="/_next/static/chunks/main-app-e3082ce30f616b80.js" async=""></script><meta name="robots" content="noindex"/><title>tether — Visual linting</title><meta name="description" content="tether learns your UI’s existing design language, then catches drift as humans and agents make changes."/><link rel="icon" href="/knot.svg"/><title>404: This page could not be found.</title><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-21c0bda91597affb.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[1133,[],\"\"]\n3:I[4297,[],\"\"]\n4:I[1872,[],\"OutletBoundary\"]\n5:\"$Sreact.suspense\"\n8:I[1872,[],\"ViewportBoundary\"]\na:I[1872,[],\"MetadataBoundary\"]\nc:I[7567,[],\"default\",1]\n:HL[\"/_next/static/css/7fbedb5502367c5e.css\",\"style\"]\n7:X\n0:{\"P\":null,\"c\":[\"\",\"_not-found\",\"\"],\"q\":\"\",\"i\":false,\"f\":[[[\"\",{\"children\":[\"_not-found\",{\"children\":[\"__PAGE__\",{},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4624],[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/7fbedb5502367c5e.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null,[\"$\",\"$L4\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.MetadataOutlet\",\"children\":\"$@6\"}]}]]}],{},null,false,null]},null,false,\"$7\"]},null,false,null],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$L8\",null,{\"children\":\"$L9\"}],[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$La\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.Metadata\",\"children\":\"$Lb\"}]}]}],null]}],false]],\"m\":\"$undefined\",\"G\":[\"$c\",[]],\"S\":true,\"h\":null,\"r\":\"$undefined\",\"s\":\"$undefined\",\"a\":\"$undefined\",\"l\":\"$undefined\",\"p\":\"$undefined\",\"d\":\"$undefined\",\"b\":\"l8qlXIaCzWbRx2ZWOTp_i\"}\n7:C\n9:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nd:I[2569,[],\"IconMark\"]\n6:null\nb:[[\"$\",\"title\",\"0\",{\"children\":\"tether — Visual linting\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"tether learns your UI’s existing design language, then catches drift as humans and agents make changes.\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/knot.svg\"}],[\"$\",\"$Ld\",\"3\",{}]]\n"])</script></body></html>
1
+ <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/b754cdc60c7b43bb.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-21c0bda91597affb.js"/><script src="/_next/static/chunks/c7879cf7-33323d49ba6da96a.js" async=""></script><script src="/_next/static/chunks/158-65f5491737ca4c2e.js" async=""></script><script src="/_next/static/chunks/main-app-e7e077df7256067f.js" async=""></script><meta name="robots" content="noindex"/><title>tether — Visual linting</title><meta name="description" content="tether learns your UI’s existing design language, then catches drift as humans and agents make changes."/><link rel="icon" href="/knot.svg"/><title>404: This page could not be found.</title><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-21c0bda91597affb.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[1133,[],\"\"]\n3:I[4297,[],\"\"]\n4:I[1872,[],\"OutletBoundary\"]\n5:\"$Sreact.suspense\"\n8:I[1872,[],\"ViewportBoundary\"]\na:I[1872,[],\"MetadataBoundary\"]\nc:I[7567,[],\"default\",1]\n:HL[\"/_next/static/css/b754cdc60c7b43bb.css\",\"style\"]\n7:X\n0:{\"P\":null,\"c\":[\"\",\"_not-found\",\"\"],\"q\":\"\",\"i\":false,\"f\":[[[\"\",{\"children\":[\"_not-found\",{\"children\":[\"__PAGE__\",{},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4624],[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/b754cdc60c7b43bb.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null,[\"$\",\"$L4\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.MetadataOutlet\",\"children\":\"$@6\"}]}]]}],{},null,false,null]},null,false,\"$7\"]},null,false,null],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$L8\",null,{\"children\":\"$L9\"}],[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$La\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.Metadata\",\"children\":\"$Lb\"}]}]}],null]}],false]],\"m\":\"$undefined\",\"G\":[\"$c\",[]],\"S\":true,\"h\":null,\"r\":\"$undefined\",\"s\":\"$undefined\",\"a\":\"$undefined\",\"l\":\"$undefined\",\"p\":\"$undefined\",\"d\":\"$undefined\",\"b\":\"TBRjO9Fdl2ToZdlLQ7gIf\"}\n7:C\n9:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nd:I[2569,[],\"IconMark\"]\n6:null\nb:[[\"$\",\"title\",\"0\",{\"children\":\"tether — Visual linting\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"tether learns your UI’s existing design language, then catches drift as humans and agents make changes.\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/knot.svg\"}],[\"$\",\"$Ld\",\"3\",{}]]\n"])</script></body></html>