squint-mcp 0.1.0__tar.gz

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 (51) hide show
  1. squint_mcp-0.1.0/.gitignore +9 -0
  2. squint_mcp-0.1.0/CHANGELOG.md +23 -0
  3. squint_mcp-0.1.0/LICENSE +21 -0
  4. squint_mcp-0.1.0/PKG-INFO +103 -0
  5. squint_mcp-0.1.0/README.md +80 -0
  6. squint_mcp-0.1.0/pyproject.toml +60 -0
  7. squint_mcp-0.1.0/src/squint_mcp/__init__.py +6 -0
  8. squint_mcp-0.1.0/src/squint_mcp/capture.py +154 -0
  9. squint_mcp-0.1.0/src/squint_mcp/checks/__init__.py +17 -0
  10. squint_mcp-0.1.0/src/squint_mcp/checks/low_contrast_real.py +166 -0
  11. squint_mcp-0.1.0/src/squint_mcp/checks/text_clipped.py +133 -0
  12. squint_mcp-0.1.0/src/squint_mcp/config.py +111 -0
  13. squint_mcp-0.1.0/src/squint_mcp/js/collect_elements.js +184 -0
  14. squint_mcp-0.1.0/src/squint_mcp/js/fill_text.js +24 -0
  15. squint_mcp-0.1.0/src/squint_mcp/js/stabilize.js +34 -0
  16. squint_mcp-0.1.0/src/squint_mcp/models.py +115 -0
  17. squint_mcp-0.1.0/src/squint_mcp/server.py +30 -0
  18. squint_mcp-0.1.0/src/squint_mcp/tools/__init__.py +0 -0
  19. squint_mcp-0.1.0/src/squint_mcp/tools/detect_visual_bugs.py +137 -0
  20. squint_mcp-0.1.0/src/squint_mcp/tools/inspect_element.py +92 -0
  21. squint_mcp-0.1.0/src/squint_mcp/tools/ping.py +19 -0
  22. squint_mcp-0.1.0/src/squint_mcp/vision.py +128 -0
  23. squint_mcp-0.1.0/tests/conftest.py +66 -0
  24. squint_mcp-0.1.0/tests/fixtures/Ahem.ttf +0 -0
  25. squint_mcp-0.1.0/tests/fixtures/box.html +166 -0
  26. squint_mcp-0.1.0/tests/fixtures/checks-order.html +45 -0
  27. squint_mcp-0.1.0/tests/fixtures/huge-text.html +25 -0
  28. squint_mcp-0.1.0/tests/fixtures/low-contrast-bounds.html +166 -0
  29. squint_mcp-0.1.0/tests/fixtures/low-contrast-bug.html +59 -0
  30. squint_mcp-0.1.0/tests/fixtures/low-contrast-clean.html +55 -0
  31. squint_mcp-0.1.0/tests/fixtures/low-contrast-fill.html +51 -0
  32. squint_mcp-0.1.0/tests/fixtures/low-contrast-reach.html +130 -0
  33. squint_mcp-0.1.0/tests/fixtures/many.html +44 -0
  34. squint_mcp-0.1.0/tests/fixtures/motion.html +79 -0
  35. squint_mcp-0.1.0/tests/fixtures/pixel-cost.html +95 -0
  36. squint_mcp-0.1.0/tests/fixtures/polling-briefly.html +16 -0
  37. squint_mcp-0.1.0/tests/fixtures/polling.html +14 -0
  38. squint_mcp-0.1.0/tests/fixtures/responsive.html +38 -0
  39. squint_mcp-0.1.0/tests/fixtures/selectors.html +105 -0
  40. squint_mcp-0.1.0/tests/fixtures/text-clipped-bounds.html +62 -0
  41. squint_mcp-0.1.0/tests/fixtures/text-clipped-bug.html +44 -0
  42. squint_mcp-0.1.0/tests/fixtures/text-clipped-clean.html +172 -0
  43. squint_mcp-0.1.0/tests/fixtures/text-clipped-strip.html +69 -0
  44. squint_mcp-0.1.0/tests/fixtures/visit.html +23 -0
  45. squint_mcp-0.1.0/tests/helpers.py +56 -0
  46. squint_mcp-0.1.0/tests/test_detect_visual_bugs.py +488 -0
  47. squint_mcp-0.1.0/tests/test_inspect_element.py +507 -0
  48. squint_mcp-0.1.0/tests/test_low_contrast_real.py +500 -0
  49. squint_mcp-0.1.0/tests/test_ping.py +114 -0
  50. squint_mcp-0.1.0/tests/test_selectors.py +154 -0
  51. squint_mcp-0.1.0/tests/test_text_clipped.py +163 -0
@@ -0,0 +1,9 @@
1
+ .agents/
2
+ .claude/
3
+ .cursor/
4
+ .windsurf/
5
+ __pycache__/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ dist/
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Squint stays on 0.x while the Finding schema is still changing: removing or renaming a Finding field, a tool or a tool parameter is a breaking change. Adding a Check or changing a threshold is not breaking, but is always recorded here.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-08
10
+
11
+ First release, on PyPI as `squint-mcp`.
12
+
13
+ ### Added
14
+
15
+ - MCP server over stdio, started with the `squint-mcp` command.
16
+ - `ping` tool: returns the server name and version and echoes an optional `message`.
17
+ - `inspect_element` tool: for one element of a page (`url`, `selector`, optional `viewport`), returns its computed styles, box model, the colours sampled from its pixels, whether the page stabilized, and a crop.
18
+ - `detect_visual_bugs` tool: for a page (`url`, optional `viewports`, optional `checks`), runs Checks on one Capture per viewport and returns Findings ordered by severity, whether each viewport stabilized, and up to five crops. Each Finding carries a selector that is unique on the page and works in `inspect_element`.
19
+ - `text-clipped` Check: reports text cut off horizontally by its own box (`overflow-x: hidden` or `clip`), confirmed in the pixels. A cut of 8px or more is `major`, a smaller one `minor`; a cut of 1px is not reported.
20
+ - `low-contrast-real` Check (WCAG 2.2 SC 1.4.3): reports text whose contrast is below 4.5:1, or 3:1 for large text (24px, or 18.66px bold), against the background painted behind it. The background is sampled from the pixels, so it is right over an image or a gradient; where it varies, the worst tenth of the text decides. A ratio under 3:1 is `major`, any other `minor`. Text with an `opacity` below 1, on itself or on an ancestor, is not judged.
21
+ - `evidence.measured` of a Finding can hold strings besides numbers: `low-contrast-real` reports `textColor` and `sampledBackground` as `#rrggbb`.
22
+ - Findings of the same severity and viewport come in document order whatever the Check that reported them, and two Findings on one element in the order of their Check names. The order of `checks` does not change the result.
23
+ - `inspect_element` and the `low-contrast-real` Check stay fast on a large element painted in millions of colours, such as a long photo-heavy page: an element, or the text of an element, of more than 262,144 pixels (512x512) is sampled down to that many before its colours are counted, so `sampledColors` and the contrast of such an element come from the sample. Smaller ones are counted whole, as before ([#4](https://github.com/sh4wty1/squint-mcp/issues/4)).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Fassi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: squint-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server that finds visual bugs in a rendered web page by crossing DOM/CSS with pixels, without a baseline.
5
+ Project-URL: Repository, https://github.com/sh4wty1/squint-mcp
6
+ Project-URL: Issues, https://github.com/sh4wty1/squint-mcp/issues
7
+ Project-URL: Changelog, https://github.com/sh4wty1/squint-mcp/blob/main/CHANGELOG.md
8
+ Author: Lucas Fassi
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: accessibility,mcp,playwright,visual-testing,wcag
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: mcp>=2.3.0
19
+ Requires-Dist: pillow>=12.3.0
20
+ Requires-Dist: playwright>=1.63.0
21
+ Requires-Dist: pydantic>=2.13.5
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Squint
25
+
26
+ Squint is an MCP server that examines a rendered web page by crossing what the browser reports (DOM/CSS) with what is actually painted (pixels), and reports the visual problems it finds without needing a baseline.
27
+
28
+ It is built for coding agents that have just produced UI and need to know whether the page actually looks right: which element is broken, and what the evidence is.
29
+
30
+ ## Install
31
+
32
+ Requires [uv](https://docs.astral.sh/uv/). uv installs Python 3.12 for you if it is missing. Two steps:
33
+
34
+ ```bash
35
+ uvx --from squint-mcp playwright install chromium # the browser Squint drives
36
+ uvx squint-mcp # the server
37
+ ```
38
+
39
+ The first step goes through `squint-mcp` so the Chromium downloaded is the one its Playwright expects; uv warns that `playwright` comes from a dependency, which is expected. On Linux, add `--with-deps` to also install the system libraries Chromium needs.
40
+
41
+ The server speaks MCP over stdio, so started by hand it just waits for a client. Register it in your MCP client instead:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "squint": {
47
+ "command": "uvx",
48
+ "args": ["squint-mcp"]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ In Claude Code: `claude mcp add squint -- uvx squint-mcp`.
55
+
56
+ Then call `ping` to confirm the connection.
57
+
58
+ ## Tools
59
+
60
+ The server exposes three tools:
61
+
62
+ | Tool | Input | Output |
63
+ | --- | --- | --- |
64
+ | `ping` | `message` (optional string) | server `name`, `version`, and the echoed `message` |
65
+ | `inspect_element` | `url`, `selector`, `viewport` (optional) | one element's computed styles, box model, sampled colours, `stabilized`, and a crop |
66
+ | `detect_visual_bugs` | `url`, `viewports` (optional), `checks` (optional) | Findings ordered by severity, `stabilized` per viewport, and up to five crops |
67
+
68
+ `detect_visual_bugs` runs two Checks: `text-clipped` (text cut off by its own box) and `low-contrast-real` (text whose contrast against the background sampled from the pixels is below WCAG 2.2 SC 1.4.3, so it is right over images and gradients). See [`docs/SPEC.md`](https://github.com/sh4wty1/squint-mcp/blob/main/docs/SPEC.md) for the v0.1 specification and [`CHANGELOG.md`](https://github.com/sh4wty1/squint-mcp/blob/main/CHANGELOG.md) for what each release contains.
69
+
70
+ ## Development
71
+
72
+ Run from a checkout:
73
+
74
+ ```bash
75
+ git clone https://github.com/sh4wty1/squint-mcp.git
76
+ cd squint-mcp
77
+ uv sync
78
+ uv run playwright install chromium # the tests drive a real Chromium
79
+ uv run squint-mcp
80
+ ```
81
+
82
+ To point an MCP client at the checkout, use `"command": "uv"` with `"args": ["run", "--directory", "/path/to/squint-mcp", "squint-mcp"]`.
83
+
84
+ One command each:
85
+
86
+ ```bash
87
+ uv run pyright # typecheck (strict)
88
+ uv run ruff check # lint
89
+ uv run ruff format --check # format check
90
+ uv run pytest # tests
91
+ ```
92
+
93
+ Tests drive the server through an in-memory MCP client, the same surface a real client uses. See [`CONTRIBUTING.md`](https://github.com/sh4wty1/squint-mcp/blob/main/CONTRIBUTING.md).
94
+
95
+ ## Documentation
96
+
97
+ - [`CONTEXT.md`](https://github.com/sh4wty1/squint-mcp/blob/main/CONTEXT.md): domain glossary (Capture, Check, Finding, Profile, Audit)
98
+ - [`docs/SPEC.md`](https://github.com/sh4wty1/squint-mcp/blob/main/docs/SPEC.md): v0.1 specification
99
+ - [`docs/adr/`](https://github.com/sh4wty1/squint-mcp/tree/main/docs/adr): architecture decision records
100
+
101
+ ## License
102
+
103
+ [MIT](https://github.com/sh4wty1/squint-mcp/blob/main/LICENSE)
@@ -0,0 +1,80 @@
1
+ # Squint
2
+
3
+ Squint is an MCP server that examines a rendered web page by crossing what the browser reports (DOM/CSS) with what is actually painted (pixels), and reports the visual problems it finds without needing a baseline.
4
+
5
+ It is built for coding agents that have just produced UI and need to know whether the page actually looks right: which element is broken, and what the evidence is.
6
+
7
+ ## Install
8
+
9
+ Requires [uv](https://docs.astral.sh/uv/). uv installs Python 3.12 for you if it is missing. Two steps:
10
+
11
+ ```bash
12
+ uvx --from squint-mcp playwright install chromium # the browser Squint drives
13
+ uvx squint-mcp # the server
14
+ ```
15
+
16
+ The first step goes through `squint-mcp` so the Chromium downloaded is the one its Playwright expects; uv warns that `playwright` comes from a dependency, which is expected. On Linux, add `--with-deps` to also install the system libraries Chromium needs.
17
+
18
+ The server speaks MCP over stdio, so started by hand it just waits for a client. Register it in your MCP client instead:
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "squint": {
24
+ "command": "uvx",
25
+ "args": ["squint-mcp"]
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ In Claude Code: `claude mcp add squint -- uvx squint-mcp`.
32
+
33
+ Then call `ping` to confirm the connection.
34
+
35
+ ## Tools
36
+
37
+ The server exposes three tools:
38
+
39
+ | Tool | Input | Output |
40
+ | --- | --- | --- |
41
+ | `ping` | `message` (optional string) | server `name`, `version`, and the echoed `message` |
42
+ | `inspect_element` | `url`, `selector`, `viewport` (optional) | one element's computed styles, box model, sampled colours, `stabilized`, and a crop |
43
+ | `detect_visual_bugs` | `url`, `viewports` (optional), `checks` (optional) | Findings ordered by severity, `stabilized` per viewport, and up to five crops |
44
+
45
+ `detect_visual_bugs` runs two Checks: `text-clipped` (text cut off by its own box) and `low-contrast-real` (text whose contrast against the background sampled from the pixels is below WCAG 2.2 SC 1.4.3, so it is right over images and gradients). See [`docs/SPEC.md`](https://github.com/sh4wty1/squint-mcp/blob/main/docs/SPEC.md) for the v0.1 specification and [`CHANGELOG.md`](https://github.com/sh4wty1/squint-mcp/blob/main/CHANGELOG.md) for what each release contains.
46
+
47
+ ## Development
48
+
49
+ Run from a checkout:
50
+
51
+ ```bash
52
+ git clone https://github.com/sh4wty1/squint-mcp.git
53
+ cd squint-mcp
54
+ uv sync
55
+ uv run playwright install chromium # the tests drive a real Chromium
56
+ uv run squint-mcp
57
+ ```
58
+
59
+ To point an MCP client at the checkout, use `"command": "uv"` with `"args": ["run", "--directory", "/path/to/squint-mcp", "squint-mcp"]`.
60
+
61
+ One command each:
62
+
63
+ ```bash
64
+ uv run pyright # typecheck (strict)
65
+ uv run ruff check # lint
66
+ uv run ruff format --check # format check
67
+ uv run pytest # tests
68
+ ```
69
+
70
+ Tests drive the server through an in-memory MCP client, the same surface a real client uses. See [`CONTRIBUTING.md`](https://github.com/sh4wty1/squint-mcp/blob/main/CONTRIBUTING.md).
71
+
72
+ ## Documentation
73
+
74
+ - [`CONTEXT.md`](https://github.com/sh4wty1/squint-mcp/blob/main/CONTEXT.md): domain glossary (Capture, Check, Finding, Profile, Audit)
75
+ - [`docs/SPEC.md`](https://github.com/sh4wty1/squint-mcp/blob/main/docs/SPEC.md): v0.1 specification
76
+ - [`docs/adr/`](https://github.com/sh4wty1/squint-mcp/tree/main/docs/adr): architecture decision records
77
+
78
+ ## License
79
+
80
+ [MIT](https://github.com/sh4wty1/squint-mcp/blob/main/LICENSE)
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "squint-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server that finds visual bugs in a rendered web page by crossing DOM/CSS with pixels, without a baseline."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ authors = [{ name = "Lucas Fassi" }]
9
+ keywords = ["mcp", "visual-testing", "accessibility", "playwright", "wcag"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: Software Development :: Testing",
16
+ ]
17
+ dependencies = [
18
+ "mcp>=2.3.0",
19
+ "pillow>=12.3.0",
20
+ "playwright>=1.63.0",
21
+ "pydantic>=2.13.5",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/sh4wty1/squint-mcp"
26
+ Issues = "https://github.com/sh4wty1/squint-mcp/issues"
27
+ Changelog = "https://github.com/sh4wty1/squint-mcp/blob/main/CHANGELOG.md"
28
+
29
+ [project.scripts]
30
+ squint-mcp = "squint_mcp.server:main"
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["src", "tests", "CHANGELOG.md"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/squint_mcp"]
41
+
42
+ [tool.pyright]
43
+ include = ["src", "tests"]
44
+ typeCheckingMode = "strict"
45
+
46
+ [tool.ruff]
47
+ target-version = "py312"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP", "B"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+
55
+ [dependency-groups]
56
+ dev = [
57
+ "pyright>=1.1.414",
58
+ "pytest>=9.1.1",
59
+ "ruff>=0.16.10",
60
+ ]
@@ -0,0 +1,6 @@
1
+ """Squint: an MCP server that finds visual bugs in rendered web pages."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ SERVER_NAME = "squint-mcp"
6
+ __version__ = version(SERVER_NAME)
@@ -0,0 +1,154 @@
1
+ """Capture production: the only module allowed to import `playwright` (ADR-0002)."""
2
+
3
+ import asyncio
4
+ import io
5
+ import sys
6
+ from collections.abc import AsyncGenerator
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+
11
+ from mcp.server.mcpserver.exceptions import ToolError
12
+ from PIL import Image, ImageChops
13
+ from playwright.async_api import Browser, Page, Playwright, async_playwright
14
+ from playwright.async_api import Error as PlaywrightError
15
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError
16
+
17
+ from squint_mcp import config
18
+ from squint_mcp.models import Capture, Element, Viewport
19
+
20
+ _JS = Path(__file__).parent / "js"
21
+ _STABILIZE = (_JS / "stabilize.js").read_text(encoding="utf-8")
22
+ _COLLECT_ELEMENTS = (_JS / "collect_elements.js").read_text(encoding="utf-8")
23
+ _FILL_TEXT = (_JS / "fill_text.js").read_text(encoding="utf-8")
24
+
25
+
26
+ class BrowserSession:
27
+ """The one Chromium of a server run: launched on first use, then reused."""
28
+
29
+ def __init__(self) -> None:
30
+ self._playwright: Playwright | None = None
31
+ self._browser: Browser | None = None
32
+ self._lock = asyncio.Lock()
33
+
34
+ async def browser(self) -> Browser:
35
+ # The lock keeps two concurrent first calls from launching two browsers.
36
+ async with self._lock:
37
+ # A browser that crashed or was closed is launched again.
38
+ if self._browser is None or not self._browser.is_connected():
39
+ try:
40
+ self._playwright = (
41
+ self._playwright or await async_playwright().start()
42
+ )
43
+ self._browser = await self._playwright.chromium.launch()
44
+ except PlaywrightError as error:
45
+ raise ToolError(
46
+ f"Could not launch Chromium: {_first_line(error)}. "
47
+ # This interpreter, so the fix reaches the environment
48
+ # the server runs in, uvx cache or checkout alike.
49
+ "If it is not installed, run: "
50
+ f'"{sys.executable}" -m playwright install chromium'
51
+ ) from error
52
+ return self._browser
53
+
54
+ async def close(self) -> None:
55
+ if self._browser is not None:
56
+ await self._browser.close()
57
+ if self._playwright is not None:
58
+ await self._playwright.stop()
59
+
60
+
61
+ @asynccontextmanager
62
+ async def browser_lifespan(_: object) -> AsyncGenerator[BrowserSession]:
63
+ """Server lifespan: the session lives, and its browser dies, with the server."""
64
+ session = BrowserSession()
65
+ try:
66
+ yield session
67
+ finally:
68
+ await session.close()
69
+
70
+
71
+ def _first_line(error: PlaywrightError) -> str:
72
+ """Playwright appends call logs and banners; the first line says what failed."""
73
+ return error.message.splitlines()[0]
74
+
75
+
76
+ async def _screenshot(page: Page) -> Image.Image:
77
+ return Image.open(io.BytesIO(await page.screenshot(full_page=True))).convert("RGB")
78
+
79
+
80
+ async def _filled(page: Page, color: str) -> Image.Image:
81
+ """The page with every glyph painted in `color`."""
82
+ await page.evaluate(_FILL_TEXT, color)
83
+ return await _screenshot(page)
84
+
85
+
86
+ async def capture(
87
+ session: BrowserSession, url: str, viewport: Viewport, selector: str
88
+ ) -> Capture:
89
+ """Open `url` in a fresh context, stabilize it and capture it.
90
+
91
+ The Capture holds the elements matched by `selector`, which reaches into
92
+ open shadow roots; `*` is every element of the page.
93
+ """
94
+ scheme = urlsplit(url).scheme
95
+ if scheme not in ("http", "https", "file"):
96
+ raise ToolError(
97
+ f'Unsupported URL scheme "{scheme}"; use http://, https:// or file://.'
98
+ )
99
+ browser = await session.browser()
100
+ context = await browser.new_context(
101
+ viewport={"width": viewport.width, "height": viewport.height},
102
+ device_scale_factor=1,
103
+ )
104
+ try:
105
+ # The tool's total timeout is the only clock; Playwright's own would race it.
106
+ context.set_default_timeout(0)
107
+ page = await context.new_page()
108
+ try:
109
+ await page.goto(url, wait_until="load")
110
+ except PlaywrightError as error:
111
+ raise ToolError(f"Could not load {url}: {_first_line(error)}") from error
112
+ await page.evaluate(_STABILIZE)
113
+ try:
114
+ await page.wait_for_load_state(
115
+ "networkidle", timeout=config.NETWORK_IDLE_TIMEOUT_S * 1000
116
+ )
117
+ stabilized = True
118
+ except PlaywrightTimeoutError:
119
+ # Polling and analytics keep some pages busy forever: capture them anyway.
120
+ stabilized = False
121
+ matches = page.locator(f"css={selector}")
122
+ try:
123
+ # count() only parses and runs the selector: a failure is its syntax.
124
+ await matches.count()
125
+ except PlaywrightError as error:
126
+ raise ToolError(f'Invalid selector "{selector}".') from error
127
+ collected = await matches.evaluate_all(
128
+ _COLLECT_ELEMENTS,
129
+ {
130
+ "properties": list(config.CAPTURE_COMPUTED_PROPERTIES),
131
+ "textLimit": config.TEXT_EXCERPT_MAX_CHARS,
132
+ "transformMinSizeDiffPx": config.TRANSFORM_MIN_SIZE_DIFF_PX,
133
+ },
134
+ )
135
+ pixels = await _screenshot(page)
136
+ # After the collector, which reads the page's own styles, and after the
137
+ # pixels: from here on the text is not painted as the page asked (AD-004).
138
+ # ponytail: every Capture pays for the three layers, whatever reads them;
139
+ # take them on request if calls get slow.
140
+ background = await _filled(page, "transparent")
141
+ black = await _filled(page, "rgb(0, 0, 0)")
142
+ white = await _filled(page, "rgb(255, 255, 255)")
143
+ finally:
144
+ # A client cancellation keeps cancelling every await: shield the close.
145
+ await asyncio.shield(context.close())
146
+ return Capture(
147
+ viewport=viewport,
148
+ stabilized=stabilized,
149
+ elements=[Element.model_validate(element) for element in collected],
150
+ pixels=pixels,
151
+ background=background,
152
+ # Only glyphs differ between the two fills, by how much they cover a pixel.
153
+ ink=ImageChops.difference(black, white).convert("L"),
154
+ )
@@ -0,0 +1,17 @@
1
+ """The Checks, by name. A Check turns one Capture into Findings, in the order of the
2
+ Capture's elements, and never touches the browser (ADR-0002).
3
+
4
+ Adding a Check is one module in this package and one entry in `CHECKS`.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+
9
+ from squint_mcp.checks import low_contrast_real, text_clipped
10
+ from squint_mcp.models import Capture, Finding
11
+
12
+ Check = Callable[[Capture], list[Finding]]
13
+
14
+ CHECKS: dict[str, Check] = {
15
+ "text-clipped": text_clipped.check,
16
+ "low-contrast-real": low_contrast_real.check,
17
+ }
@@ -0,0 +1,166 @@
1
+ """The `low-contrast-real` Check: text too close in colour to what is painted behind it.
2
+
3
+ The background is read from the pixels, not from `background-color`, so it is right
4
+ over an image or a gradient (WCAG 2.2 SC 1.4.3).
5
+ """
6
+
7
+ import math
8
+ import re
9
+
10
+ from squint_mcp import config
11
+ from squint_mcp.models import Capture, Element, Evidence, Finding, Viewport
12
+ from squint_mcp.vision import text_backgrounds
13
+
14
+ type Rgb = tuple[int, int, int]
15
+
16
+ # The styles that explain the contrast. `background-color` is what a tool that
17
+ # reads only the DOM would have compared the text with, and `color` is what it
18
+ # would have taken for the text where the fill paints the glyphs in another colour.
19
+ _EVIDENCE_PROPERTIES = (
20
+ "color",
21
+ "-webkit-text-fill-color",
22
+ "background-color",
23
+ "background-image",
24
+ "font-size",
25
+ "font-weight",
26
+ )
27
+
28
+ # How Chromium serializes a computed sRGB colour.
29
+ # ponytail: a colour in another space (`oklch(...)`, `color(display-p3 ...)`) keeps
30
+ # its own syntax and the element is skipped; convert it if such text gets common.
31
+ _COLOR = re.compile(r"rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)")
32
+
33
+
34
+ def _luminance(color: Rgb) -> float:
35
+ """The relative luminance of WCAG 2.2."""
36
+
37
+ def linear(channel: int) -> float:
38
+ value = channel / 255
39
+ return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
40
+
41
+ red, green, blue = color
42
+ return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue)
43
+
44
+
45
+ def _contrast(one: Rgb, other: Rgb) -> float:
46
+ """The contrast ratio of WCAG 2.2, from 1 to 21."""
47
+ lighter, darker = sorted((_luminance(one), _luminance(other)), reverse=True)
48
+ return (lighter + 0.05) / (darker + 0.05)
49
+
50
+
51
+ def _over(color: Rgb, alpha: float, behind: Rgb) -> Rgb:
52
+ """`color` at `alpha` as seen over `behind`."""
53
+ red, green, blue = (
54
+ math.floor(alpha * front + (1 - alpha) * back + 0.5)
55
+ for front, back in zip(color, behind, strict=True)
56
+ )
57
+ return red, green, blue
58
+
59
+
60
+ def _hex(color: Rgb) -> str:
61
+ return "#{:02x}{:02x}{:02x}".format(*color)
62
+
63
+
64
+ def _worst_part(element: Element, capture: Capture) -> tuple[float, Rgb, Rgb] | None:
65
+ """The contrast of the least readable part of the element's own text, with the
66
+ text colour and the background there; None when the element has no text to judge.
67
+
68
+ Text covered by another element's text is judged on that text's ink: the ink
69
+ layer does not say whose glyph a pixel belongs to.
70
+ """
71
+ if not element.own_text:
72
+ return None
73
+ # A faded text is blended with what is behind its faded ancestor, which the
74
+ # Capture does not hold: its real colour is not known.
75
+ if element.opacity < 1:
76
+ return None
77
+ color = _COLOR.fullmatch(element.computed["-webkit-text-fill-color"])
78
+ if color is None:
79
+ return None
80
+ text: Rgb = (int(color[1]), int(color[2]), int(color[3]))
81
+ alpha = 1.0 if color[4] is None else float(color[4])
82
+ # Text made transparent is hidden on purpose, as when an image replaces it.
83
+ if alpha == 0:
84
+ return None
85
+ behind = text_backgrounds(capture.background, capture.ink, element.own_text_boxes)
86
+ if not behind:
87
+ return None
88
+ # A translucent text is as dark as what shows through it.
89
+ parts = sorted(
90
+ (_contrast(seen, background), count, seen, background)
91
+ for count, background in behind
92
+ for seen in (_over(text, alpha, background),)
93
+ )
94
+ total = sum(count for _, count, _, _ in parts)
95
+ # From the least readable colour up, the first one at which the share of the
96
+ # text reached is the worst part. In integers: "exactly a tenth" is exact.
97
+ reached = 0
98
+ for ratio, count, seen, background in parts:
99
+ reached += count
100
+ if reached * 100 >= config.LOW_CONTRAST_WORST_PART_PERCENT * total:
101
+ return ratio, seen, background
102
+ raise AssertionError("the whole text is always reached")
103
+
104
+
105
+ def _required_ratio(element: Element) -> float:
106
+ font_size = float(element.computed["font-size"].removesuffix("px"))
107
+ bold = float(element.computed["font-weight"]) >= config.BOLD_MIN_WEIGHT
108
+ large = font_size >= config.LARGE_TEXT_MIN_PX or (
109
+ bold and font_size >= config.LARGE_BOLD_TEXT_MIN_PX
110
+ )
111
+ return config.CONTRAST_MIN_RATIO_LARGE if large else config.CONTRAST_MIN_RATIO
112
+
113
+
114
+ def _finding(
115
+ element: Element,
116
+ viewport: Viewport,
117
+ ratio: float,
118
+ required: float,
119
+ text: Rgb,
120
+ background: Rgb,
121
+ ) -> Finding:
122
+ # WCAG: a ratio is not rounded up to meet a threshold, so 4.478 is shown as 4.47.
123
+ shown = math.floor(ratio * 100) / 100
124
+ major = ratio < config.LOW_CONTRAST_MAJOR_BELOW_RATIO
125
+ return Finding(
126
+ check="low-contrast-real",
127
+ category="a11y",
128
+ severity="major" if major else "minor",
129
+ message=(
130
+ f"Text contrast {shown:.2f}:1 against the painted background "
131
+ f"{_hex(background)} is below the {required:g}:1 minimum"
132
+ ),
133
+ selector=element.selector,
134
+ text=element.text,
135
+ box=element.box,
136
+ viewport=viewport,
137
+ evidence=Evidence(
138
+ computed={name: element.computed[name] for name in _EVIDENCE_PROPERTIES},
139
+ measured={
140
+ "contrastRatio": shown,
141
+ "requiredRatio": required,
142
+ "textColor": _hex(text),
143
+ "sampledBackground": _hex(background),
144
+ },
145
+ ),
146
+ suggestion=(
147
+ "Change the text colour or put a solid background behind the text "
148
+ f"to reach {required:g}:1"
149
+ ),
150
+ source="WCAG 2.2 SC 1.4.3",
151
+ )
152
+
153
+
154
+ def check(capture: Capture) -> list[Finding]:
155
+ findings: list[Finding] = []
156
+ for element in capture.elements:
157
+ worst = _worst_part(element, capture)
158
+ if worst is None:
159
+ continue
160
+ ratio, text, background = worst
161
+ required = _required_ratio(element)
162
+ if ratio < required:
163
+ findings.append(
164
+ _finding(element, capture.viewport, ratio, required, text, background)
165
+ )
166
+ return findings