tether-ui
v0.7.0
Published
Visual linting for AI-coded interfaces. Learn the existing design language and detect frontend drift.
Maintainers
Readme
tether
Visual linting for AI-coded interfaces.
tether learns your UI’s existing design language, then catches drift as humans and agents make changes.
Screenshot testing asks whether pixels changed. tether asks whether a change violates the visual language already present in the product.
npx tether-ui init
npx tether-ui check
npx tether-ui reportCheck UI changes against a baseline
Quick start
Node.js 20.9+ · Chromium · a running frontend
In your frontend's repository, install the package and Chromium:
npm install --save-dev tether-ui
npx playwright install chromiumStart your frontend, then save its current styles as the baseline:
npx tether-ui init --url http://localhost:3000After changing your UI, check for drift and open the report:
npx tether-ui check
npx tether-ui reportThe package is tether-ui; its CLI executable is tether. The report opens at localhost:4317. Use --no-open to suppress browser launch or --port 4320 for another report port.
--cwd /path/to/app selects a different project directory. init prompts for a URL in an interactive terminal when there is no config; otherwise it uses http://localhost:3000. An existing baseline is never overwritten unless you pass init --force.
Guided setup and readiness checks
Available in tether-ui v0.7.0 and later.
With no config or command options, npx tether-ui init asks for the application URL, routes, and viewport presets in an interactive terminal. Press Enter to use / and all four default viewports. Start your frontend before running setup. In scripts and CI, supply options explicitly; non-interactive runs never prompt.
To check the browser and configured pages before saving a baseline:
npx tether-ui doctor --url http://localhost:3000 --routes /,/settings --viewports desktop,mobiledoctor runs the same navigation, interaction, readiness, and capture steps as a scan. It removes its temporary screenshots afterward and does not write a baseline, report, or config to your project. Configured hooks still run and may interact with the application. A failed check exits with code 2 and identifies the failed step; missing Chromium includes an installation command. Start the app and check the route if navigation fails. Configure readiness assertions for authenticated or dynamic pages. A successful check confirms capture readiness, not that the design is correct.
Use the same selections to create your reference:
npx tether-ui init --url http://localhost:3000 --routes /,/settings --viewports desktop,mobile
npx tether-ui reportReview the captured pages and learned styles, then commit the reviewed baseline and contract before using them in CI. After changing the UI, run npx tether-ui check to compare it with that reference. Existing deviations may already appear in the initial report.
Preset names are desktop, laptop, tablet, and mobile. For custom sizes or an existing project, edit tether.config.ts; init refuses route/viewport flags alongside an existing config so subsequent checks keep the same coverage. doctor can temporarily override those selections without saving them. An existing baseline still requires init --force to replace it.
See visual drift
The included forma demo has a clean state and five deliberate CSS changes. Run it locally to compare them.
Why tether
A working page can still pick up an unexpected color, radius, or button height. tether points to the element and changed property so you can review it, whether the code came from a person or an agent.
| Checks | What tether looks for | | --- | --- | | Color | Visible text, backgrounds, and border colors outside the palette | | Typography | New sizes, families, weights, and explicit line heights | | Spacing | Margin, padding, and flex/grid gaps outside the inferred scale | | Shape | Unexpected corner radii, shadows, and border combinations | | Controls | New rendered button, input, select, textarea, and switch heights | | Layout | Horizontal overflow, off-screen elements, and obvious vertical text clipping |
The demo introduces exactly five CSS mutations: a rogue accent, a 20px card radius, 15px heading type, a 13px gap, and mobile overflow. A single mutation can have several consequences: changing type size can change line height; overflow can affect both a card and the document.
Review the report
The local report leads with total, high, medium, and low findings. Filter by category or severity, then select View evidence to compare current and baseline captures with element outlines. Screenshots support the computed-style finding; no pixel diff is performed. Findings repeated across viewports count once.
Missing captures, unmatched elements, and elements outside the captured viewport are labelled without an invented outline. The serialized score remains compatible with v0.1: 100 − (8 × high + 4 × medium + 2 × low), clamped to zero. It is a prioritization heuristic, not a measure of interface quality, and is no longer shown in the overview.
Inspect a finding
See the changed property, its expected values, and the same element in both captures. Outlines are drawn over the original screenshots.
Images are captured at 2× resolution. Click any image to open its full-size version.
Focused review and repair
Available in tether-ui v0.7.0 and later.
On the Visual drift page, choose Group findings → By element to review findings that share a high-confidence element identity, route, UI state, and rule context. Low-confidence and legacy targets stay separate. Existing filters still apply, and every finding remains visible. A group is a review aid, not a claim that its findings share one cause.
Use Copy fix request on a group or in a finding's evidence view to hand a focused task to a coding agent. The request includes stable finding IDs, actual and expected values, rule details, viewports, evidence references, and whether each finding blocks the current check. Evidence paths refer to the local report or downloaded artifact, not application source files. Download fix request works when clipboard access is unavailable.
Approved findings are excluded. Below-threshold findings and existing layout findings remain identified as non-blocking when the selected policy excludes them. The request asks the agent to verify its target, preserve behavior, avoid changing baselines or approvals to silence findings, and rerun the same check after a fix. You still review and apply any proposed code change; tether does not execute repairs.
Saved styles
The visual contract renders the palette, type scale, spacing values, radii, shadows, borders, and control heights discovered in your baseline.
How it works
- Observe. Playwright loads each route at every configured viewport in a fresh, light-mode browser context. After load and font readiness, it disables CSS motion, waits a short settling period, and reads computed styles.
- Normalize. Pixel values within 0.05px of an integer snap to it; other pixels round to one decimal. RGB/RGBA/hex normalize to uppercase hex (with alpha when needed). Fully transparent colors collapse to one value. Font weights and whitespace normalize too.
- Infer. A token normally needs two distinct elements. Repeated properties and repeated viewport captures do not inflate its count. If a category has no recurring token, its most frequent value is retained so a small interface still has a usable contract.
- Compare. Values outside the contract are findings, except unchanged values at the same route, viewport, semantic identity, and property in the baseline. This preserves existing one-off choices without legitimizing them everywhere.
- Explain. Findings carry a stable ID, semantic identity and confidence, debugging selector, CSS property, current value, expected tokens, route, viewport names, deterministic severity, and screenshot evidence.
Identity prefers unique stable IDs and test attributes, then semantic names, concise text, and stable class hints. Ambiguous or generated signals fall back to a selector with low confidence. Matching is exact, never fuzzy; selector matching remains available for old baselines without identities.
Near-identical colors (at most two RGB channel levels apart) and subpixel dimensional differences (at most 0.25px) are tolerated. Spacing is only collected from box spacing and actual flex/grid gaps. Margins larger than 96px in magnitude are excluded as likely layout offsets (including browser-resolved auto margins). Inherited text styles are counted only on direct text elements and controls. Hidden elements, SVG internals, transparent values, zero dimensions, and explicit ignores are omitted. Layout problems remain visible even when they already exist in the baseline.
Severity is intentionally simple: major overflow is high; new colors, families, shadows, and larger size changes are medium; smaller spacing/type/border changes are low. See src/drift/compare.ts for the complete rules.
Agent workflow
npx tether-ui check --agent > visual-lint.mdPaste the resulting Markdown into Codex, Claude Code, Cursor, or your preferred agent. It contains instructions to preserve behavior, followed by specific findings and expected values. No LLM calls, credentials, or network service is involved. Output ordering and IDs are stable for the same findings; scan timestamps are omitted from Markdown. Progress and ANSI styling are omitted from stdout in agent mode.
For scripts and tooling:
npx tether-ui check --json > findings.jsonJSON uses schema: "tether.check" and version: 1, with severity/category counts and compact findings. It omits timestamps, progress text, ANSI codes, and random scan paths. screenshotRef is a file-relative JSON pointer inside .tether/, such as report.json#/scan/snapshots/0/screenshot; resolve it to the stored PNG path. Keep the corresponding report, baseline, and screenshots with saved JSON output. --agent and --json are mutually exclusive; errors go to stderr with exit code 2.
Responsive checks
Defaults: 1440 × 1000, 1024 × 900, 768 × 900, 390 × 844. Each route/viewport gets an actual PNG capture. The Viewports screen links findings to their sizes and lets you enlarge or download each capture.
Intentional horizontal scroll containers and descendants clipped by their parents are excluded from off-screen checks. Text ellipsis and line clamps are not treated as accidental clipping. Screenshots show the viewport; style collection also covers rendered elements below the fold, up to the configured element cap.
Separate existing layout problems from new findings
Available in tether-ui v0.5.0 and later.
Layout findings now indicate whether the same problem was recorded in the baseline. The report keeps them visible and offers a Baseline status filter.
By default, tether check still fails on every active finding. To fail only on new or changed findings:
npx tether-ui check --fail-on new
npx tether-ui check --fail-on new --jsonThis excludes only verified unchanged layout findings from the failure decision. Token findings and unverified layout findings still block. Existing layout findings are not approvals: they remain in report totals, evidence, JSON, and a separate review section of the agent brief. With --json, gate.blocking gives the blocking count and gate.existing gives the retained, unapproved existing-layout count. The default --fail-on all preserves the existing exit-code policy.
Matching requires complete, unique captures with the same route, state, color scheme, and viewport name and dimensions. An element must match uniquely with the same tag, bounds, layout kind, measured value, and severity. Document overflow is compared by its exact measured value and severity. Missing or ambiguous matches, changed geometry, and partial scans remain blocking. An unchanged measurement does not establish that its underlying cause is unchanged.
Layout findings carry an additive baselineStatus field (existing or new-or-changed); the latter includes findings that cannot be verified unchanged. JSON schema versions 1 and 2 retain their existing fields and counts. With --fail-on new, an additive gate object records the policy and counts. Existing-layout finding IDs are distinguished from new/changed occurrences so the two cannot merge across viewports. Stored approval tuples remain valid; legacy reports without baseline status remain blocking until rescanned. No rebaseline is required.
Configuration
init creates tether.config.ts when one does not exist. Config is executable local code; load only configs you trust.
export default {
url: 'http://localhost:3000',
routes: ['/', '/settings'],
viewports: [
{ name: 'desktop', width: 1440, height: 1000 },
{ name: 'laptop', width: 1024, height: 900 },
{ name: 'tablet', width: 768, height: 900 },
{ name: 'mobile', width: 390, height: 844 }
],
minOccurrences: 2,
maxElements: 5000,
settleMs: 250,
ignore: ['[data-tether-ignore]', '.live-clock']
};--config path/to/config.ts selects another config. --url overrides its URL; query parameters apply to each configured route unless that route supplies its own value. Routes are origin-relative paths. A route or viewport change requires an explicit new baseline so coverage never silently shrinks.
.tether/
├── contract.json # Editable tokens and distinct-element counts
├── baseline.json # Original observations and scan configuration
├── report.json # Latest findings, contract, scan, and agent brief
└── screenshots/ # Baseline and subsequent viewport capturesAdd intentional values to contract.json to accept them globally, or use tether init --force to replace the baseline after review. tether never updates the baseline during check. Scans accumulate screenshot directories; remove unneeded scan directories when their reports are no longer needed.
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.
Component-aware contracts
Global inference remains the default. Opt in to named scopes when component variants need different rules:
export default {
url: 'http://localhost:3000',
scopes: [
{
name: 'primary-button',
variant: 'primary',
selector: '[data-component="button"][data-variant="primary"]',
minOccurrences: 2,
parts: { label: '[data-part="label"]' }
},
{
name: 'card',
selector: '.card',
parts: { title: '.card-title' },
overrides: [
{ part: 'title', category: 'fontSizes', property: 'font-size', values: ['24px'] }
]
}
]
};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.
- 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
rootpart. 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. - 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
minOccurrencesper scope for smaller families. Multiple responsive values can qualify independently. A single instance is not sufficient by default. Explicitly setting a scope’sminOccurrences: 1opts into trusting one reviewed instance (v0.5.0 and later; see below). - 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.
- 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.
- 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.
Reuse a reviewed component with one baseline instance
Available in tether-ui v0.5.0 and later.
A page may contain only one instance of a reusable control. By default, that one instance does not establish a component rule, so adding another can trigger findings for its rare styles. After reviewing the control, opt in for its specific role:
scopes: [
{
name: 'timer-toggle',
selector: 'button[aria-label="Toggle timer"]',
minOccurrences: 1
}
]This accepts another matching control using the observed property values. A changed control is checked against this scope’s values, including when its new value is valid elsewhere on the page. Keep different roles and variants in separate scopes; a broad selector would explicitly authorize sharing their values.
The global threshold and other scopes are unchanged. Counts still represent independent instances: repeat states and viewports do not increase the count. Rules remain specific to scope, part, category, and property; explicit overrides take precedence. Partial baselines still disable inferred component rules. Values observed in different states or viewports are pooled within a scope, as with existing component rules; this setting does not introduce state-specific policy.
Changing this threshold requires reviewing the current UI and intentionally rebaselining with tether init --force. The single instance is your chosen reference, not statistical evidence of a convention. Use explicit overrides when you want to specify values instead of learning them. Older tether versions reject scoped contracts configured with a threshold of one.
Accuracy benchmark
Run npm run benchmark from the development checkout. The frozen corpus 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.
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 describe what these numbers establish; they are not an accuracy claim for arbitrary applications.
REAL-WORLD BENCHMARK
Across 45 labeled scenarios on Pomo, Focus Timer, and Kardinal, tether measured 85.7% precision, 75.0% recall, and 80.0% F1 at the scenario level. All 45 evaluated; no infrastructure failures. The corpus includes controlled source mutations and legitimate variants, with pinned revisions and exact target matching.
Run npm run benchmark:real after npm ci and npm run browser:install. Regenerate reports from saved measurements with npm run benchmark:real:report.
Full report · Individual outcomes · Methodology · Interactive dashboard · Raw JSON
Download and open docs/real-world/dashboard.html locally for filters and diagnostic details; GitHub does not execute HTML dashboards. This small, purposive sample includes three false alarms and six missed violations. It is not a population accuracy claim; finding-level metrics and unjudged additional findings are reported separately.
Interactive states and authenticated pages
Available in v0.4.0. Define named scenarios in tether.config.ts to capture a modal, menu, form, or authenticated view. Scenario IDs are stable baseline keys, not display descriptions.
export default {
url: 'http://localhost:3000',
routes: ['/account'],
storageState: '.auth/session.json', // optional; relative to this config file
sensitiveSelectors: ['[data-private]', 'input[type="password"]'],
scenarios: [
{
id: 'default',
routes: ['/account'],
ready: async (page) => {
await page.locator('[data-testid="account"]')
.waitFor({ state: 'visible' });
},
},
{
id: 'open-settings',
routes: ['/account'],
colorScheme: 'light', // or 'dark'; independent of your browser
timeoutMs: 30000,
setup: async (page, context) => {
// Optional pre-navigation setup, such as an init script.
},
interact: async (page) => {
await page.getByRole('button', { name: 'Settings' }).click();
},
ready: async (page) => {
await page.getByRole('dialog').waitFor({ state: 'visible' });
},
},
],
};Each route/state/viewport starts in a fresh browser context. The lifecycle is load storage state → setup → navigate → interact → ready → settle → collect and mask screenshot → close. A route with scenarios captures only those scenarios; include default explicitly if you want it too. Routes without scenarios retain their initial-load capture. ready is required on every explicit scenario; for authentication, assert an element only present after successful sign-in. Every route using storageState must have an explicit scenario.
Create the Playwright storage-state file externally using a test account. Keep .auth/ ignored by Git and outside .tether/; tether reads the file but never copies or rewrites it. It contains session credentials. Config hooks execute trusted local code and must not log secrets. They receive Playwright Page/BrowserContext objects; they are not a sandbox. Asynchronous work is bounded by timeoutMs (default 30 seconds, maximum 120 seconds), with action/navigation timeouts and no automatic retries. Synchronous blocking code inside a hook cannot be interrupted; avoid it.
Failed navigation, interactions, readiness, or capture exit 2, identify the route/state/viewport/step without printing the original error, remove incomplete captures, and preserve the last complete baseline/report. Different state or color-scheme coverage requires init --force. Old snapshots remain the default state with their existing finding IDs. Named-state findings include optional state fields in JSON/evidence and state labels in the report and agent brief; they never merge across states. Approvals include the exact state ID.
All URL query values and fragments are removed from saved/displayed URLs; use non-secret paths, query names, scenario IDs and configuration labels. Raw URLs remain available only during navigation. If init --url contains query values, its generated config reads the URL from TETHER_URL instead of embedding those values. Hooks, authentication paths and storage contents are omitted from persisted configuration.
sensitiveSelectors masks matching screenshot rectangles and removes semantic identity signals from sensitive elements, their descendants, ancestors and elements labelled by them. Layout measurements remain available; masked targets use structural selectors. This is not automatic secret detection: use sanitized test data and mask every sensitive region, including any labels or duplicate values elsewhere. Review screenshots before sharing them. Masking does not add iframe or shadow-DOM coverage.
Approving intentional changes
Available in tether-ui v0.4.0 and later.
An approval accepts one finding occurrence without changing the visual contract or baseline. Start with a fresh check and take the finding ID from its JSON output:
npx tether-ui check --json
npx tether-ui approve <finding-id> --property padding-left --viewport desktop --reason "Reviewed compact card spacing"The second command prints a preview and writes nothing. Review the exact element, property, normalized value, route, viewport dimensions, and component context, then repeat with --yes to save it:
npx tether-ui approve <finding-id> --property padding-left --viewport desktop --reason "Reviewed compact card spacing" --yes
npx tether-ui check
npx tether-ui approvals list
npx tether-ui approvals revoke <approval-id>
npx tether-ui checkCommit tether.approvals.json in the project root. Its versioned records contain an ID, reason, route, exact state ID, target, category, individual property, exact normalized value, viewport name/dimensions, and optional component context. There are no timestamps, screenshot paths, wildcard matching rules, or baseline updates. Use approvals list --json for machine-readable records. If checks use a custom configuration, pass the same --config to approve or approvals list.
- A grouped finding can contain several properties and viewport occurrences. Both
--propertyand--viewportare required; approving left padding on desktop never approves right padding or mobile. Each additional occurrence needs its own reviewed record. - Unique, high-confidence semantic identities are preferred. A weak or legacy identity requires explicitly adding
--selector; this pins both the exact saved selector and its identity. Ambiguous or partial snapshots cannot be approved. Changes to weak identity, target, value, viewport dimensions, route, or component context leave the new finding active. - Values use normal CSS normalization, followed by exact matching—not the drift detector's near-color/subpixel tolerance. Layout approvals pin the problem kind and measured value, so worsening overflow remains active. Missing state in a hand-authored record means only
default, never every future state. approvereads the saved scan; it does not inspect unsaved code or perform a new scan. Preview, creation, listing, and revocation leave baseline/contract/report files untouched. Runcheckafter edits to refresh status. Listing without a saved report showsunknown; otherwisematched/stalerefers to that saved scan.- Unmatched approvals remain visible as stale; they are never deleted automatically. Revoke a stale record when it is no longer needed. Reasons are editable; duplicate tuples/IDs, overlapping matches, invalid files, and concurrent edits fail with an actionable error.
Checks exit 0 when no active findings remain, 1 for active drift, and 2 for invalid configuration/approvals or scan failures. Approved findings retain their evidence and reasons in the report's Approved findings filter and in agent output; they are not repair instructions. The overview and severity counts describe active findings, with separate approved/stale counts.
Without an approvals file, existing finding IDs and v1 machine output remain unchanged. With the file present (even empty), report/JSON output uses version 2: report.issues holds active findings; report.approvals.approved and .stale preserve reviewed and unmatched records. check --json includes status on every finding and summary.active/summary.approved totals, severity/category counts, and summary.staleApprovals. summary.total counts displayed active plus approved findings; splitting a previously grouped finding can increase this total. Consumers must branch on the schema version. Older clients do not apply approvals and should not be used for approval-aware CI.
Severity thresholds
Available in tether-ui v0.6.0 and later.
Use tether check --min-severity medium to fail only on medium and high severity findings, or --min-severity high for high severity only. The default is low, which preserves existing behavior. All findings stay in the report and JSON; below-threshold findings are not approvals.
Combine this with --fail-on new to exclude verified existing layout findings as well. Scanning/configuration failures still fail regardless of the severity threshold. JSON includes the optional gate.minSeverity field only for a non-default threshold; update the GitHub reporter together with the CLI before using it.
CI
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.
# .tether is ignored by default. Add only reviewed, non-sensitive baseline files.
git add -f .tether/contract.json .tether/baseline.json
npx tether-ui checkExit codes: 0 no drift, 1 drift found, 2 configuration or scanning failure. Agent and JSON modes use the same codes. Do not generate a fresh baseline from each PR: that would approve the changes being tested.
The GitHub PR integration updates one summary comment with actual/expected values and downloadable evidence. Its application workflow example starts the app, checks the saved baseline, and separates scanning from the job allowed to post comments. Fork PRs retain a workflow summary when commenting is unavailable. This repository's CI workflow builds, tests, and runs the clean-to-drifted demo integration test.
Architecture
src/
cli/ Commander commands, terminal, Markdown and JSON output
scanner/ Playwright collection, semantic identity and browser lifecycle
contract/ Frequency-based token inference
drift/ Contract comparison, grouping, severity and score
report/ Read-only loopback report server
utils/ Config, normalization and JSON persistence
report/ Next.js + React + Tailwind + Lucide, exported at build time
demo/ Forma: one consistent UI, five deliberate CSS mutations
scripts/ End-to-end verification and real README asset generation
tests/ Deterministic core tests
docs/assets/ Composed hero and actual browser capturesThe Next.js static export ships inside the npm package and reads local JSON at runtime. The CLI serves it on 127.0.0.1; users do not need a development server or a Next.js build. Scanner/browser setup follows Playwright's browser installation. No database, accounts, cloud backend, telemetry, or AI API.
Limitations
- This checks computed styles and basic geometry, not pixel-perfect appearance, semantic design intent, accessibility, or image content.
- No pseudo-elements, shadow DOM or iframe traversal, multi-tab workflows, MFA automation, or automatic secret discovery. Named scenarios can capture hover/focus and lazy-rendered content through explicit interactions and readiness checks.
- Screenshots use light mode by default (scenarios can select dark mode), with reduced motion. Browser defaults are filtered heuristically, not traced back to authored stylesheets.
- Semantic identities survive small DOM refactors when unique stable signals remain. Dynamic labels, ambiguous elements, and structural fallbacks can still produce noisy matches. This is not a component identity guarantee.
- Legitimate new design tokens still need human review. Font metrics, OS rendering, dynamic content, and CSS values beyond RGB/hex can affect results.
- Large DOMs are capped and visibly marked as partial scans. A huge number of findings can reduce the heuristic score to zero.
- Baselines and screenshots may contain private page content. They stay local, but review them before committing or sharing.
Development and contributing locally
From a checkout of this repository:
npm install
npm run browser:install
npm run build
npm run demoThe forma demo opens at localhost:3000, with pastel glass panels over a cloud background. Leave it running. In a second terminal at the repository root, use the locally built CLI:
alias tether="node '$PWD/dist/cli/index.js'"
tether init
# Clean scan: exit 0.
tether check
tether reportIf a baseline already exists, review it before replacing it with tether init --force.
Check the intentional drift
With a clean demo baseline saved:
# Drifted scan: exit 1, intentionally.
tether check --url 'http://localhost:3000/?drift=1'
tether check --agent --url 'http://localhost:3000/?drift=1'
tether reportThe demo's Introduce drift link switches its visible state. The URL override above tells the scanner to inspect that state; switching a browser tab alone does not change the scan target. The clean and drifted URLs share the same route identity for comparison.
Development checks
npm run build
npm test
npm run test:e2eThe E2E test starts its own demo and report servers on available ports. Each run creates a fresh temporary baseline, checks the clean and drifted pages, verifies report evidence, agent/JSON output, semantic identity, and legacy baselines, then stops its servers and removes its temporary files. No manually started servers or existing .tether folder are needed.
See CONTRIBUTING.md. To regenerate the README images, run npm run assets.
License
MIT.







