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.
- squint_mcp-0.1.0/.gitignore +9 -0
- squint_mcp-0.1.0/CHANGELOG.md +23 -0
- squint_mcp-0.1.0/LICENSE +21 -0
- squint_mcp-0.1.0/PKG-INFO +103 -0
- squint_mcp-0.1.0/README.md +80 -0
- squint_mcp-0.1.0/pyproject.toml +60 -0
- squint_mcp-0.1.0/src/squint_mcp/__init__.py +6 -0
- squint_mcp-0.1.0/src/squint_mcp/capture.py +154 -0
- squint_mcp-0.1.0/src/squint_mcp/checks/__init__.py +17 -0
- squint_mcp-0.1.0/src/squint_mcp/checks/low_contrast_real.py +166 -0
- squint_mcp-0.1.0/src/squint_mcp/checks/text_clipped.py +133 -0
- squint_mcp-0.1.0/src/squint_mcp/config.py +111 -0
- squint_mcp-0.1.0/src/squint_mcp/js/collect_elements.js +184 -0
- squint_mcp-0.1.0/src/squint_mcp/js/fill_text.js +24 -0
- squint_mcp-0.1.0/src/squint_mcp/js/stabilize.js +34 -0
- squint_mcp-0.1.0/src/squint_mcp/models.py +115 -0
- squint_mcp-0.1.0/src/squint_mcp/server.py +30 -0
- squint_mcp-0.1.0/src/squint_mcp/tools/__init__.py +0 -0
- squint_mcp-0.1.0/src/squint_mcp/tools/detect_visual_bugs.py +137 -0
- squint_mcp-0.1.0/src/squint_mcp/tools/inspect_element.py +92 -0
- squint_mcp-0.1.0/src/squint_mcp/tools/ping.py +19 -0
- squint_mcp-0.1.0/src/squint_mcp/vision.py +128 -0
- squint_mcp-0.1.0/tests/conftest.py +66 -0
- squint_mcp-0.1.0/tests/fixtures/Ahem.ttf +0 -0
- squint_mcp-0.1.0/tests/fixtures/box.html +166 -0
- squint_mcp-0.1.0/tests/fixtures/checks-order.html +45 -0
- squint_mcp-0.1.0/tests/fixtures/huge-text.html +25 -0
- squint_mcp-0.1.0/tests/fixtures/low-contrast-bounds.html +166 -0
- squint_mcp-0.1.0/tests/fixtures/low-contrast-bug.html +59 -0
- squint_mcp-0.1.0/tests/fixtures/low-contrast-clean.html +55 -0
- squint_mcp-0.1.0/tests/fixtures/low-contrast-fill.html +51 -0
- squint_mcp-0.1.0/tests/fixtures/low-contrast-reach.html +130 -0
- squint_mcp-0.1.0/tests/fixtures/many.html +44 -0
- squint_mcp-0.1.0/tests/fixtures/motion.html +79 -0
- squint_mcp-0.1.0/tests/fixtures/pixel-cost.html +95 -0
- squint_mcp-0.1.0/tests/fixtures/polling-briefly.html +16 -0
- squint_mcp-0.1.0/tests/fixtures/polling.html +14 -0
- squint_mcp-0.1.0/tests/fixtures/responsive.html +38 -0
- squint_mcp-0.1.0/tests/fixtures/selectors.html +105 -0
- squint_mcp-0.1.0/tests/fixtures/text-clipped-bounds.html +62 -0
- squint_mcp-0.1.0/tests/fixtures/text-clipped-bug.html +44 -0
- squint_mcp-0.1.0/tests/fixtures/text-clipped-clean.html +172 -0
- squint_mcp-0.1.0/tests/fixtures/text-clipped-strip.html +69 -0
- squint_mcp-0.1.0/tests/fixtures/visit.html +23 -0
- squint_mcp-0.1.0/tests/helpers.py +56 -0
- squint_mcp-0.1.0/tests/test_detect_visual_bugs.py +488 -0
- squint_mcp-0.1.0/tests/test_inspect_element.py +507 -0
- squint_mcp-0.1.0/tests/test_low_contrast_real.py +500 -0
- squint_mcp-0.1.0/tests/test_ping.py +114 -0
- squint_mcp-0.1.0/tests/test_selectors.py +154 -0
- squint_mcp-0.1.0/tests/test_text_clipped.py +163 -0
|
@@ -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)).
|
squint_mcp-0.1.0/LICENSE
ADDED
|
@@ -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,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
|