Labsco
tactual-dev logo

tactual-mcp

from tactual-dev

Screen-reader navigation cost analyzer that measures the actual navigation effort for assistive-technology users by building a weighted graph from Playwright accessibility snapshots and scoring each target under real assistive-technology profiles (NVDA, JAWS, VoiceOver, TalkBack, generic mobile).

🔥🔥🔥🔥✓ VerifiedFreeQuick setup

Tactual

Tactual logo

Screen-reader navigation cost analyzer. Measures how many keystrokes a screen-reader user needs to discover, reach, and operate every interactive target on your page — under a specific AT profile (NVDA, JAWS, VoiceOver).

What it does

Existing accessibility tools check conformance — is the ARIA correct? Is the contrast ratio sufficient?

Tactual measures navigation cost — how many actions does it take a screen-reader user to reach the checkout button? What happens if they overshoot? Can they even discover it exists? Does the menu actually open on Enter, or only on click? Does focus land on the first menuitem or stay stuck on the trigger?

How it works:

  • Captures Playwright accessibility snapshots + screen-reader announcement simulation
  • Optionally explores hidden branches (menus, dialogs, tabs, disclosures) and probes them with real keyboard events, including APG-style widget contracts and form-error flows
  • Builds a navigation graph with entry points (landmarks, headings, linear Tab) and scores every target
  • Optionally validates predicted paths against @guidepup/virtual-screen-reader for calibration

Tactual is a developer tool for analyzing your own sites and staging environments. Run it locally, in CI, or via the MCP server in your editor. It is not a public scanning service.

How it fits

Tactual complements conformance scanners such as axe-core, Lighthouse, and Pa11y. Those tools are still the right first pass for broad WCAG and ARIA rule coverage. Tactual is aimed at the next question: after a page has valid markup, how expensive is it for an AT user to discover, reach, and operate the important targets?

Use Tactual for screen-reader navigation-cost triage, path tracing, measured keyboard/widget evidence, before/after diffs, CI prioritization, and MCP workflows where an agent needs compact findings with source selectors and remediation candidates. Use real screen readers and manual testing for final validation of critical journeys, timing-sensitive flows, browser/AT settings, and implementation patterns that intentionally differ from a common APG example.

Agent quick path: this README is a product overview plus reference. Agents should start with docs/AGENT-RECIPES.md for task patterns and docs/MCP-TOOLS.md for full MCP schemas, then come back here only for product context, install notes, and release-surface examples.

AT Profiles

ProfilePlatformDescription
generic-mobile-web-sr-v0MobileNormalized mobile SR primitives (default)
voiceover-ios-v0MobileVoiceOver on iOS Safari — rotor-based navigation
talkback-android-v0MobileTalkBack on Android Chrome — reading controls
nvda-desktop-v0DesktopNVDA on Windows — browse mode quick keys
jaws-desktop-v0DesktopJAWS on Windows — virtual cursor with auto forms mode

Profiles define the cost of each navigation action, score dimension weights, costSensitivity (scales the reachability decay curve), and context-dependent modifiers. See src/profiles/ for implementation details.

Mobile profile limitation. The voiceover-ios-v0 and talkback-android-v0 profiles model action costs and SR announcement phrasing accurately, but Tactual's keyboard probes (--probe) only test desktop interactions (Tab, Enter, Escape). They do NOT simulate touch gestures (single-tap, double-tap, swipe-right, three-finger swipe, rotor rotation, etc.). For mobile profiles, score dimensions reflect predicted cost from the profile model — not measured behavior. Real device testing remains necessary to verify mobile a11y.

Visual modes. The nvda-desktop-v0 and jaws-desktop-v0 profiles declare a visualModes matrix (light/dark × forced-colors on/off) so the analyzer captures per-icon contrast under each combination. Mobile and generic profiles omit this — Windows High Contrast Mode isn't a realistic mobile concern. See Visibility checks below.

Visibility checks

When the active profile declares a visualModes matrix, Tactual re-emulates each (colorScheme, forcedColors) combination after the initial capture and samples per-icon computed styles. The finding builder compares each icon's computed fill against the nearest non-transparent ancestor background-color and emits a penalty when contrast falls below the WCAG 1.4.11 non-text threshold (3:1).

Four penalty wordings, three scoring tiers:

PenaltyTriggerOperability impact
Icon invisible in <mode>Contrast < 1.5:1, no adjacent text labelOperability capped at 60
Decorative icon invisible in <mode>Contrast < 1.5:1, control has visible text labelOperability −5
Low icon contrast in <mode>Contrast 1.5–3.0:1, no adjacent text labelOperability −5
Author-set SVG fill in <mode>Contrast OK in Playwright (≥3:1) but mode is forced-colors: active and the fill is an author CSS literal (non-system, non-currentColor)Operability −2

The check skips icons that are already HCM-safe: fill="currentColor", fill: ButtonText (or any system color), forced-color-adjust: none (author opt-out), or computed fill === color (CSS-applied currentColor). Low-contrast icons next to a visible text label are suppressed entirely — the label identifies the control and the icon is reinforcement.

Why the substitution-risk tier exists. Different user HCM themes have different Canvas/ButtonText/system-color values. An author literal fill (e.g. svg { fill: #e4e6e6 }) may contrast well against Chromium's default HCM palette but poorly against a specific user theme. Browser rendering of the literal itself is consistent across Playwright, Chrome, and Edge for forced-color-adjust: preserve-parent-color (the default for SVG paths) — the concrete concern is theme variability, not a hidden OS-paint substitution. Tactual flags the pattern so you know to verify in real Edge with a representative HCM theme, not because Playwright's contrast measurement is misleading.

Disable explicitly via --no-check-visibility, checkVisibility: false in tactual.json, or checkVisibility: false on the MCP analyze_url tool. Force-enable via --check-visibility even when a profile doesn't declare modes (no-op without modes).

The check adds roughly +50–200ms per declared mode per page — re-emulating media is cheap; there's no new browser context per mode.

Scoring Presets

Presets bundle focus filters and priority mappings for common use cases. They layer under config files and CLI flags (preset → tactual.json → CLI flags).

PresetUse caseFocusCritical targets
ecommerce-checkoutShopping flowsmaincheckout, cart, payment, buy
docs-siteDocumentationmain, navigationsearch, nav
dashboardWeb appsmain, navigationsave, submit, create, delete, search
form-heavyForm pagesmainsubmit, save, next, continue, error
npx tactual analyze-url https://shop.com --preset ecommerce-checkout
npx tactual presets  # list all presets with details

Presets suppress cookie banners and analytics targets by default. To override, use --exclude or set priority in tactual.json. Presets do not compose — only one --preset can be active.

Scoring

Each target receives a 5-dimension score vector:

DimensionWhat it measures
DiscoverabilityCan the user tell the target exists?
ReachabilityWhat is the navigation cost to get there?
OperabilityDoes the control behave predictably?
RecoveryHow hard is it to recover from overshooting?
Interop RiskHow likely is AT/browser support variance? (penalty)

Dimension weights vary by profile:

ProfileDROReccostSensitivity
generic-mobile-web-sr-v00.300.400.200.101.0
voiceover-ios-v00.300.350.200.151.1
talkback-android-v00.250.450.200.101.3
nvda-desktop-v00.350.250.300.100.7
jaws-desktop-v00.300.250.350.100.6

Composite: Weighted geometric mean: overall = exp(sum(w_i * ln(score_i)) / sum(w_i)) - interopRisk. Each dimension is floored at 1 before the log to avoid log(0). A zero in any dimension eliminates that dimension's contribution to the geometric mean, significantly dragging the overall score down -- you cannot operate what you cannot reach.

Severity bands:

ScoreBandMeaning
90-100StrongLow concern
75-89AcceptableImprovable
60-74ModerateShould be triaged
40-59HighLikely meaningful friction
0-39SevereLikely blocking

Diagnostics

Tactual emits diagnostics for capture reliability, page structure, visual access, runtime evidence, ARIA validity, and repeated cost patterns. Warnings are review prompts, not automatic conformance failures. Many visual/content checks are heuristic and should be confirmed in context before filing a defect.

CodeLevelMeaning
blocked-by-bot-protectionerrorBot/challenge page detected; captured content is not the intended page.
empty-pageerrorNo targets found at all.
okinfoCapture produced a target set without reliability warnings.
possibly-degraded-contentwarningSuspiciously few targets for an HTTP page.
sparse-contentwarningOnly 1-4 targets found.
possible-login-wallwarningAuth-gated content or login redirect suspected.
possible-cookie-wallinfoCookie consent may obscure content.
redirect-detectedinfo/warningCapture landed on a different URL or domain.
timeout-during-renderwarningA requested render wait did not complete before capture.
framework-detectedinfoFrontend framework signals were detected during capture.
spa-route-changesinfoSPA route changes happened during analysis.
exploration-no-new-stateswarning--explore ran but did not reveal additional states.
frames-descendedinfoIframe descent captured or skipped child frames.
auto-scrolledinfoAuto-scroll ran before capture and reports what it surfaced.
banners-dismissedinfoCookie/consent banner dismissal was attempted.
tab-order-walkedinfo/warningTab-order walk recorded focus stops; warns on positive tabindex.
viewport-divergencewarningDesktop/mobile viewport diff found missing targets, landmarks, or headings.
no-headingswarningNo heading elements found.
heading-skipwarningHeading hierarchy skips a level, such as h1 -> h3.
empty-headingwarningHeading exists but has no text.
numeric-headingwarningHeading text is only digits, punctuation, or trivial single-character content.
h1-countinfo/warningPage has no useful single H1, or has multiple H1s worth reviewing.
no-landmarkswarningNo landmark regions found.
no-main-landmarkwarningMissing <main> landmark.
no-banner-landmarkinfoMissing <header> / banner landmark.
no-contentinfo-landmarkinfoMissing <footer> / contentinfo landmark.
no-nav-landmarkinfoMissing <nav> / navigation landmark.
landmark-demotedwarningHTML landmark exists but is demoted by nesting context.
structural-summaryinfoOne-line structural overview.
no-skip-linkwarningNo skip-to-content link on pages with 5+ targets.
broken-skip-linkwarningSkip-style link points to a missing fragment target.
skip-link-not-firstwarningA skip link exists but is not reachable in the first two Tab stops.
visual-order-divergencewarningVisual order appears to diverge from DOM/SR navigation order.
shared-structural-issuewarningA penalty affecting >50% of targets is promoted to page level.
redundant-tab-stopswarningMultiple link targets create repeated Tab stops to the same destination.
data-flow-dependenciesinfoExplored states reveal controls that become enabled only after prior action.
form-summaryinfo/warningSummarizes forms and warns when a form appears to lack a submit control.
missing-autocompletewarningStandard form fields lack useful autocomplete tokens or disable them.
empty-interactivewarningInteractive target has no accessible name.
fake-interactive-elementswarningClickable non-semantic elements are not keyboard/SR reachable.
cdp-click-listenerswarningCDP found click-like listeners on non-interactive elements.
ambiguous-link-nameswarningLinks with the same accessible name point to different destinations.
media-without-controlswarningAudio/video lacks controls and is not hidden.
duplicate-idwarningDuplicate id values can break labels and ARIA references.
nested-interactivewarningInteractive controls are nested inside other interactive controls.
meta-refreshwarningPage auto-refreshes or redirects via meta refresh.
missing-image-altwarningImages lack alt attributes.
suspicious-image-altwarningImage alt text looks like filler or a filename-like placeholder.
missing-iframe-titlewarningIframes lack a title or accessible label.
missing-html-langwarning<html lang> is missing or does not look like a BCP 47 language tag.
poor-document-titlewarningDocument title is missing, empty, too short, or generic.
viewport-blocks-zoomwarningViewport meta settings restrict user zoom.
low-contrast-textwarningInteractive text or headings fail WCAG-style text contrast thresholds.
color-only-conveyancewarningText appears to rely on color alone to convey meaning.
color-blindness-contrast-failwarningText loses contrast under simulated color-vision deficiency.
lang-switch-without-markerwarningText language appears to change without a lang marker.
invalid-aria-rolewarningNon-standard ARIA role is present.
unknown-aria-attrwarningUnknown aria-* attribute is present.
invalid-aria-attr-valuewarningARIA attribute value is outside the allowed value set.
missing-required-aria-attrwarningARIA role is missing a required state or property.
aria-naming-prohibitedwarningName is applied to a role that prohibits naming.
unsupported-aria-attr-for-rolewarningARIA attribute is not supported on the element's role.

Exploration

The --explore flag activates bounded branch exploration:

  • Opens menus, tabs, disclosures, accordions, and dialogs
  • Captures new accessibility states from hidden UI
  • Marks discovered targets as requiresBranchOpen
  • Respects depth, action count, target count, and novelty budgets
  • Safe-action policy blocks destructive interactions

Exploration is useful for pages with significant hidden UI (e.g., dropdown menus, tabbed interfaces, modal dialogs).

Exploration candidates are sorted by a stable key (role + name) before iterating, so the same page content produces the same exploration order across runs.

Probes

The --probe flag measures whether important interactive patterns work after they appear in the accessibility tree. Probes are opt-in because they send real keyboard events and add runtime. Since 0.4.0 this includes generic focus/activation checks, menu contracts, modal dialog contracts, trigger-to-dialog flows, tabs, disclosures, comboboxes, listboxes, and required-field error flows. Probe findings include evidence summaries so reports distinguish measured failures from modeled or heuristic scoring.

Goal-directed controls keep deep probes useful on complex SPAs:

NeedCLIMCP/Action fieldEffect
Analyze one subtree--scope-selector "#drawer"scopeSelector / scope-selectorCaptures, scores, and probes only the selected subtree(s).
Probe one subtree--probe-selector "#drawer"probeSelector / probe-selectorKeeps page-wide scoring but spends probe budget only inside the selected subtree(s).
Open one branch first--entry-selector "[aria-controls='menu']"entrySelector / entry-selectorActivates the trigger before capture/probe and prioritizes newly revealed targets.
Aim at a known target--goal-target "checkout"goalTarget / goal-targetNarrows probing to matching target ids, names, roles, kinds, or selectors.
Aim by glob--goal-pattern "*dialog*"goalPattern / goal-patternSame as goal target, with glob matching.
Spend budget by intent--probe-strategy modal-return-focusprobeStrategy / probe-strategyRuns the probe families relevant to all, overlay, composite-widget, form, navigation, modal-return-focus, or menu-pattern.

For example, to evaluate a modal branch without crawling unrelated menus:

npx tactual analyze-url https://app.example.com/settings \
  --profile nvda-desktop-v0 \
  --probe \
  --entry-selector "[aria-controls='profile-dialog']" \
  --probe-strategy modal-return-focus \
  --format markdown

Exploration budgets

BudgetCLI flagDefaultPurpose
Depth--explore-depth3Max recursion depth
Actions--explore-budget50Total click budget across all branches
Targets--explore-max-targets2000Stop if accumulated targets exceed this
Time--explore-timeout60000 msBound total exploration time, including initial probes, branch captures, and revealed-state probes

Sizing guidance:

Page typeSuggested settingsWhy
Marketing site, docs page, blogdefaultsSmall surface, defaults rarely hit
Dashboard with sidebar/menu--explore-depth 3 --explore-budget 50 (defaults)Captures one level of menu opens
Complex app (Figma, Notion, etc.)--explore-depth 4 --explore-budget 100 --explore-max-targets 5000Deeper menus, more state
Pages with very large hidden UI (emoji pickers, color grids)--explore-max-targets 10000 plus --exclude "emoji-*"Cap or filter out the firehose
Quick triage of unknown page--explore-depth 1 --explore-budget 10Just open obvious branches, fast

If exploration hits the timeout before opening useful branches, raise --explore-timeout and --explore-budget slowly, or use --entry-selector, --probe-selector, and --probe-strategy to spend the same budget on the branch you care about. If output has duplicate-looking targets, lower --explore-depth (deep recursion can re-discover the same elements through different paths).

SPA framework detection

Tactual detects when SPA content has rendered before capturing the accessibility tree. Detected frameworks: React, Next.js, Vue, Nuxt, Angular, Svelte, and SvelteKit. Generic HTML5 content signals (landmarks, headings, navigation, links) are also checked. For SPAs not covered by auto-detection, use --wait-for-selector (CLI) or waitForSelector (MCP/API) to specify a CSS selector that indicates your app has hydrated.

After initial framework detection, Tactual uses convergence-based polling — repeatedly snapshotting the accessibility tree until the target count stabilizes — which works regardless of framework.

For SPA-heavy apps, these opt-in capture helpers are useful:

npx tactual analyze-url https://app.example.com \
  --wait-for-selector "main" \
  --detect-routes \
  --auto-scroll \
  --descend-frames \
  --diff-viewports
  • --detect-routes records pushState, replaceState, popstate, and hashchange events that happen during analysis.
  • --auto-scroll surfaces IntersectionObserver-driven lazy content before capture.
  • --descend-frames appends iframe targets with frame URL attribution. Same-origin frames use Playwright's frame-scoped accessibility snapshot; Chromium falls back to CDP for cross-origin OOPIFs when the normal snapshot path is inaccessible. Firefox/WebKit keep the existing skip behavior for inaccessible frames.
  • --diff-viewports catches target, landmark, or heading content that disappears between desktop and mobile viewports.
  • --dismiss-banners, --probe-hover, and --walk-tab-order add targeted runtime evidence for common SPA overlays and focus-order bugs.

Known-pages benchmark

For release evidence against complex public SPA/component-library pages, run:

npm run benchmark:known-pages

The script builds the package, runs analyze-url with the SPA helper stack enabled, and writes run-results.json, summary.json, and REPORT.md under build/known-pages-*. It is intentionally not a CI gate: public sites change, block automation, and serve different content over time. Use the report to spot drift and category-level surprises, then use local fixtures or project-owned pages for deterministic regression gates. For a bounded smoke run or APG/W3C capture-quality probes, call the script directly, for example node scripts/known-pages-corpus.mjs --build --limit 1 or node scripts/known-pages-corpus.mjs --build --include-capture-probes.

Regression Tracking

Compare two analysis runs to catch regressions:

# Save a baseline
npx tactual analyze-url https://your-app.com --format json --output baseline.json

# After changes, run again and diff
npx tactual analyze-url https://your-app.com --format json --output candidate.json
npx tactual diff-results baseline.json candidate.json

The diff shows targets that improved, regressed, or changed severity, plus penalties resolved and added. In CI, use the comment-on-pr action input to post results on every pull request automatically.

Interop Risk

Tactual includes a static snapshot of ARIA role/attribute support data derived from a11ysupport.io and the ARIA-AT project. Roles with known cross-AT/browser support gaps receive an interop risk penalty.

RoleRiskNote
button, link, heading0Well-supported
dialog5Focus management varies
combobox8Most interop-problematic pattern
tree10Poorly supported outside JAWS
application15Dangerous if misused

Interpreting Findings

Tactual findings intentionally mix several evidence domains:

  • SR navigation: landmarks, headings, labels, branch discovery, sequential traversal cost, and modeled announcements.
  • Keyboard operability: focus movement, activation, Escape recovery, Tab trapping, and runtime widget probes.
  • Structural semantics: missing names, heading/landmark structure, demoted landmarks, repeated shared causes.
  • Interop risk: roles and states with known cross-AT/browser support gaps.
  • Pointer-adjacent checks: target-size and icon visibility issues that can affect users outside the screen-reader navigation model.

That means a page can have a strong screen-reader navigation score and still receive skip-link, target-size, or visibility warnings. Treat those as separate fix categories rather than contradictions.

Probe-derived APG findings are measured consistency warnings. Many widgets have valid implementation variants, especially comboboxes and disclosure-like patterns, so verify the warning against the intended pattern before treating it as a mandatory replacement. Critical flows should still be checked with the target browser/AT combination.

Output Format Recommendations

FormatTypical sizeBest for
console~8KBHuman review in terminal
markdown~11KBPRs and issue comments
json~18KBProgrammatic consumption
sarif~4-40KBGitHub Code Scanning / CI

All non-SARIF reporter formats emit summarized output by default: stats, grouped issues, remediation candidates, evidence summaries, and worst findings (capped at 15). SARIF caps at 25 results. When output is truncated, a note appears at the top. The library API exposes the full AnalysisResult; CLI and MCP reporter output is intentionally compact unless a specific field such as includeStates is requested.

For MCP usage, sarif is the default and recommended format. Use summaryOnly: true for a compact health check with stats, severity counts, diagnostics, and the top 3 issues.

Calibration

Tactual includes a calibration framework (src/calibration/, exported as tactual/calibration) for tuning scoring parameters against ground-truth datasets. See docs/CALIBRATION.md for details.

Calibration observations can also include deterministic announcement feedback from OSS review work: record observedAnnouncement when you know the tested output, or observedAnnouncementTokens when exact phrasing is noisy but role/name/state tokens are clear. Tactual compares those against its modeled announcement for the matched target and reports missing or unexpected tokens. Use tactual calibration-report or MCP calibration_report to run a dataset against saved analyze-url --full-json output and emit scoringSignals. Use tactual observe-announcement to generate or append announcement-only observations from a saved analysis, or npm run -- nvda:vm:observe -- ... in this repo to organize a controlled NVDA VM capture folder. The repo's versioned corpus lives under calibration/corpus/; run npm run calibration:corpus to audit coverage gates and npm run calibration:matrix after npm run build to rank reachability tuning work by MAE, bias, variance, and stale sequence-plan drift.

Release readiness and known boundaries are documented in docs/RELEASE_TEST_MATRIX.md, docs/LIMITATIONS.md, and docs/NVDA_VM_OBSERVER.md.

Development

npm install                    # Install dependencies
npm run build                  # Build with tsup
npm run test                   # Run unit + integration tests
npm run test:shard -- --list   # List bounded Vitest release shards
npm run test:shard -- capture  # Run one bounded Vitest shard
npm run test:shards            # Run all bounded Vitest shards
npm run test:benchmark         # Run benchmark suites
npm run typecheck              # TypeScript type checking
npm run lint                   # ESLint
npm run test:release           # Full split release gate

Security

Browser sandboxing

Tactual always runs Playwright with default Chromium sandboxing enabled. It never disables web security or modifies the browser's security model. All page interactions happen within the standard Chromium process sandbox.

Safe-action policy

When exploration is enabled (--explore), Tactual classifies interactive elements into three tiers before activating them:

TierActionExamples
SafeActivatedTabs, menu items, disclosures, accordions, same-page anchors
CautionActivated with careExternal links, ambiguous buttons
UnsafeSkippedSubmit buttons (outside search forms), Delete, sign out, purchase, deploy, unsubscribe

This is a keyword-based heuristic — it cannot detect semantic deception (e.g., a "Save" button that actually deletes data) or inspect server-side behavior. For production use, always run exploration against trusted or sandboxed environments.

URL validation

All URLs are validated before navigation. The CLI accepts http:, https:, and file: schemes so local fixtures work; MCP URL-taking tools accept only http: and https: to avoid exposing local files through agent-controlled browser navigation. javascript:, data:, blob:, and vbscript: are rejected. URLs with embedded credentials (e.g. https://user:pass@host/) are also rejected. Private/internal IP ranges are not filtered — running Tactual in an environment with access to internal services is equivalent to letting any other Playwright-driven tool reach them, so treat the URL input as trusted input.