squint-mcp 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- squint_mcp/capture.py +154 -0
- squint_mcp/checks/__init__.py +17 -0
- squint_mcp/checks/low_contrast_real.py +166 -0
- squint_mcp/checks/text_clipped.py +133 -0
- squint_mcp/config.py +111 -0
- squint_mcp/js/collect_elements.js +184 -0
- squint_mcp/js/fill_text.js +24 -0
- squint_mcp/js/stabilize.js +34 -0
- squint_mcp/models.py +115 -0
- squint_mcp/server.py +30 -0
- squint_mcp/tools/__init__.py +0 -0
- squint_mcp/tools/detect_visual_bugs.py +137 -0
- squint_mcp/tools/inspect_element.py +92 -0
- squint_mcp/tools/ping.py +19 -0
- squint_mcp/vision.py +128 -0
- squint_mcp-0.1.0.dist-info/METADATA +103 -0
- squint_mcp-0.1.0.dist-info/RECORD +21 -0
- squint_mcp-0.1.0.dist-info/WHEEL +4 -0
- squint_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- squint_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
squint_mcp/__init__.py
ADDED
squint_mcp/capture.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The `text-clipped` Check: text cut off horizontally by its own box."""
|
|
2
|
+
|
|
3
|
+
from PIL import Image
|
|
4
|
+
|
|
5
|
+
from squint_mcp import config
|
|
6
|
+
from squint_mcp.models import Box, Capture, Element, Evidence, Finding, Viewport
|
|
7
|
+
from squint_mcp.vision import is_flat
|
|
8
|
+
|
|
9
|
+
# The styles that explain the cut.
|
|
10
|
+
_EVIDENCE_PROPERTIES = (
|
|
11
|
+
"overflow-x",
|
|
12
|
+
"white-space",
|
|
13
|
+
"text-overflow",
|
|
14
|
+
"width",
|
|
15
|
+
"font-size",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _overflow_px(element: Element) -> int:
|
|
20
|
+
return element.scroll_width - element.client_width
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_resized(element: Element) -> bool:
|
|
24
|
+
"""Whether the element is painted at another size than it is laid out.
|
|
25
|
+
|
|
26
|
+
`box` is painted and the box model is layout (ADR-0003): the two differ
|
|
27
|
+
under a transform that scales, rotates or skews, on the element or on an
|
|
28
|
+
ancestor. Scroll and client width are layout pixels, so the cut has no
|
|
29
|
+
known place in the painted ones.
|
|
30
|
+
"""
|
|
31
|
+
model = element.box_model
|
|
32
|
+
layout_w = (
|
|
33
|
+
model.content.w
|
|
34
|
+
+ model.padding.left
|
|
35
|
+
+ model.padding.right
|
|
36
|
+
+ model.border.left
|
|
37
|
+
+ model.border.right
|
|
38
|
+
)
|
|
39
|
+
layout_h = (
|
|
40
|
+
model.content.h
|
|
41
|
+
+ model.padding.top
|
|
42
|
+
+ model.padding.bottom
|
|
43
|
+
+ model.border.top
|
|
44
|
+
+ model.border.bottom
|
|
45
|
+
)
|
|
46
|
+
# ponytail: a mirror or a half turn keeps both sizes and is read as no
|
|
47
|
+
# transform, so the strip is read at the painted start of the text. The text
|
|
48
|
+
# is still cut there; compare positions too if such a Finding proves wrong.
|
|
49
|
+
return (
|
|
50
|
+
abs(element.box.w - layout_w) >= config.TRANSFORM_MIN_SIZE_DIFF_PX
|
|
51
|
+
or abs(element.box.h - layout_h) >= config.TRANSFORM_MIN_SIZE_DIFF_PX
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _padding_right(element: Element) -> float:
|
|
56
|
+
"""The right edge of the padding box, in page coordinates."""
|
|
57
|
+
return element.box.x + element.box_model.border.left + element.client_width
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _edge_strip(element: Element) -> Box:
|
|
61
|
+
"""The part of the padding box within one font-size of its right edge.
|
|
62
|
+
|
|
63
|
+
A cut glyph is painted there. One font-size is wider than any gap between
|
|
64
|
+
two letters, so the strip cannot fall entirely between glyphs.
|
|
65
|
+
"""
|
|
66
|
+
border = element.box_model.border
|
|
67
|
+
font_size = float(element.computed["font-size"].removesuffix("px"))
|
|
68
|
+
right = _padding_right(element)
|
|
69
|
+
left = max(element.box.x + border.left, right - font_size)
|
|
70
|
+
return Box(
|
|
71
|
+
x=left,
|
|
72
|
+
y=element.box.y + border.top,
|
|
73
|
+
w=right - left,
|
|
74
|
+
h=element.box.h - border.top - border.bottom,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_clipped(element: Element, pixels: Image.Image) -> bool:
|
|
79
|
+
computed = element.computed
|
|
80
|
+
return (
|
|
81
|
+
element.own_text
|
|
82
|
+
# `auto` and `scroll` leave the text reachable by scrolling.
|
|
83
|
+
and computed["overflow-x"] in ("hidden", "clip")
|
|
84
|
+
# An ellipsis is a cut the author asked for.
|
|
85
|
+
and computed["text-overflow"] == "clip"
|
|
86
|
+
# Right-to-left text is cut on the other edge.
|
|
87
|
+
and computed["direction"] == "ltr"
|
|
88
|
+
# Hidden text is not cut for anyone, whatever is painted behind it.
|
|
89
|
+
and computed["visibility"] == "visible"
|
|
90
|
+
and _overflow_px(element) >= config.TEXT_CLIPPED_MIN_OVERFLOW_PX
|
|
91
|
+
# Before the pixels: the strip is only where layout and paint coincide.
|
|
92
|
+
and not _is_resized(element)
|
|
93
|
+
# scrollWidth also counts an overflowing child: the text itself has to
|
|
94
|
+
# pass the edge.
|
|
95
|
+
and element.own_text_right is not None
|
|
96
|
+
and element.own_text_right > _padding_right(element)
|
|
97
|
+
# The DOM says text overflows; the pixels say whether any of it is seen
|
|
98
|
+
# being cut. A strip of one colour is hidden text or blank overflow.
|
|
99
|
+
and not is_flat(pixels, _edge_strip(element))
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _finding(element: Element, viewport: Viewport) -> Finding:
|
|
104
|
+
overflow_px = _overflow_px(element)
|
|
105
|
+
major = overflow_px >= config.TEXT_CLIPPED_MAJOR_OVERFLOW_PX
|
|
106
|
+
return Finding(
|
|
107
|
+
check="text-clipped",
|
|
108
|
+
category="visual-bug",
|
|
109
|
+
severity="major" if major else "minor",
|
|
110
|
+
message=f"Text clipped by {overflow_px}px by its container width",
|
|
111
|
+
selector=element.selector,
|
|
112
|
+
text=element.text,
|
|
113
|
+
box=element.box,
|
|
114
|
+
viewport=viewport,
|
|
115
|
+
evidence=Evidence(
|
|
116
|
+
computed={name: element.computed[name] for name in _EVIDENCE_PROPERTIES},
|
|
117
|
+
measured={
|
|
118
|
+
"overflowPx": overflow_px,
|
|
119
|
+
"scrollWidth": element.scroll_width,
|
|
120
|
+
"clientWidth": element.client_width,
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
suggestion="Allow wrapping or reduce font-size at this width",
|
|
124
|
+
source="Squint heuristic",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check(capture: Capture) -> list[Finding]:
|
|
129
|
+
return [
|
|
130
|
+
_finding(element, capture.viewport)
|
|
131
|
+
for element in capture.elements
|
|
132
|
+
if _is_clipped(element, capture.pixels)
|
|
133
|
+
]
|
squint_mcp/config.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Thresholds and defaults, each with its source cited."""
|
|
2
|
+
|
|
3
|
+
# Source: docs/SPEC.md, Further Notes: a single desktop size, as in the draft's example.
|
|
4
|
+
DEFAULT_VIEWPORT_WIDTH = 1440
|
|
5
|
+
DEFAULT_VIEWPORT_HEIGHT = 900
|
|
6
|
+
|
|
7
|
+
# Source: docs/SPEC.md, Tools: "a total timeout of 30s with a clear error".
|
|
8
|
+
TOTAL_TIMEOUT_S = 30.0
|
|
9
|
+
|
|
10
|
+
# Source: docs/SPEC.md, Stabilization: "networkidle with a timeout of about 3s".
|
|
11
|
+
NETWORK_IDLE_TIMEOUT_S = 3.0
|
|
12
|
+
|
|
13
|
+
# Source: Squint default. docs/SPEC.md asks for "a margin around the element";
|
|
14
|
+
# 16px shows the surroundings without doubling the size of a small crop.
|
|
15
|
+
CROP_MARGIN_PX = 16
|
|
16
|
+
|
|
17
|
+
# Source: docs/SPEC.md, Images: "downscaled to about 512px on its longest side".
|
|
18
|
+
CROP_MAX_SIDE_PX = 512
|
|
19
|
+
|
|
20
|
+
# Source: Squint default. On text over a flat fill, the three most frequent colours
|
|
21
|
+
# are the fill, the text and the dominant anti-aliasing blend.
|
|
22
|
+
SAMPLED_COLORS_MAX = 3
|
|
23
|
+
|
|
24
|
+
# Source: Squint default, measured for issue #4. Counting the colours of a region
|
|
25
|
+
# where every pixel has its own takes about 2 microseconds a pixel: 15s for an
|
|
26
|
+
# element of 1440x5000. At 512x512 it stays under half a second, and the contrast
|
|
27
|
+
# of as many colours behind a text takes about two. A larger region is sampled
|
|
28
|
+
# down to that many pixels first.
|
|
29
|
+
COLOR_COUNT_MAX_PIXELS = 512 * 512
|
|
30
|
+
|
|
31
|
+
# Source: Squint default. Chromium resolves 350+ properties; these are the ones that
|
|
32
|
+
# decide how a box and its text are painted. Margin, border and padding are reported
|
|
33
|
+
# in the box model instead.
|
|
34
|
+
INSPECT_COMPUTED_PROPERTIES = (
|
|
35
|
+
"display",
|
|
36
|
+
"position",
|
|
37
|
+
"box-sizing",
|
|
38
|
+
"width",
|
|
39
|
+
"height",
|
|
40
|
+
"color",
|
|
41
|
+
"background-color",
|
|
42
|
+
"background-image",
|
|
43
|
+
"opacity",
|
|
44
|
+
"visibility",
|
|
45
|
+
"overflow-x",
|
|
46
|
+
"overflow-y",
|
|
47
|
+
"font-family",
|
|
48
|
+
"font-size",
|
|
49
|
+
"font-weight",
|
|
50
|
+
"line-height",
|
|
51
|
+
"letter-spacing",
|
|
52
|
+
"text-align",
|
|
53
|
+
"text-overflow",
|
|
54
|
+
"white-space",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Source: Squint default. What a Capture collects for every element: the inspect
|
|
58
|
+
# properties plus `direction`, which tells a Check on which edge text is cut, and
|
|
59
|
+
# `-webkit-text-fill-color`, the colour that paints the glyphs: that of `color`
|
|
60
|
+
# unless the page sets it.
|
|
61
|
+
CAPTURE_COMPUTED_PROPERTIES = (
|
|
62
|
+
*INSPECT_COMPUTED_PROPERTIES,
|
|
63
|
+
"direction",
|
|
64
|
+
"-webkit-text-fill-color",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Source: docs/SPEC.md, Images: "At most five crops per call".
|
|
68
|
+
MAX_CROPS = 5
|
|
69
|
+
|
|
70
|
+
# Source: docs/SPEC.md, Finding: "visible text excerpt, ~40 chars".
|
|
71
|
+
TEXT_EXCERPT_MAX_CHARS = 40
|
|
72
|
+
|
|
73
|
+
# Source: Squint default. scrollWidth and clientWidth are rounded integers, so a
|
|
74
|
+
# difference of 1px can be rounding alone.
|
|
75
|
+
TEXT_CLIPPED_MIN_OVERFLOW_PX = 2
|
|
76
|
+
|
|
77
|
+
# Source: Squint default. A cut of 8px or more takes a glyph or most of one at body
|
|
78
|
+
# sizes and is `major`; a smaller one is `minor`.
|
|
79
|
+
TEXT_CLIPPED_MAJOR_OVERFLOW_PX = 8
|
|
80
|
+
|
|
81
|
+
# Source: docs/adr/0003. An element whose painted size differs from its layout size
|
|
82
|
+
# by this much is under a transform. The collector is passed the same value.
|
|
83
|
+
TRANSFORM_MIN_SIZE_DIFF_PX = 1
|
|
84
|
+
|
|
85
|
+
# Source: WCAG 2.2 SC 1.4.3 Contrast (Minimum): "a contrast ratio of at least 4.5:1".
|
|
86
|
+
CONTRAST_MIN_RATIO = 4.5
|
|
87
|
+
|
|
88
|
+
# Source: WCAG 2.2 SC 1.4.3: "Large-scale text [has] a contrast ratio of at least 3:1".
|
|
89
|
+
CONTRAST_MIN_RATIO_LARGE = 3.0
|
|
90
|
+
|
|
91
|
+
# Source: WCAG 2.2, "large scale": at least 18 point, which is 24 CSS pixels.
|
|
92
|
+
LARGE_TEXT_MIN_PX = 24.0
|
|
93
|
+
|
|
94
|
+
# Source: WCAG 2.2, "large scale": at least 14 point bold, "typically 18.66px".
|
|
95
|
+
LARGE_BOLD_TEXT_MIN_PX = 18.66
|
|
96
|
+
|
|
97
|
+
# Source: CSS Fonts: the `bold` keyword computes to a font-weight of 700.
|
|
98
|
+
BOLD_MIN_WEIGHT = 700
|
|
99
|
+
|
|
100
|
+
# Source: Squint default. 3:1 is the lowest ratio WCAG accepts for any text: under
|
|
101
|
+
# it the Finding is `major`, from it up to the required ratio it is `minor`.
|
|
102
|
+
LOW_CONTRAST_MAJOR_BELOW_RATIO = 3.0
|
|
103
|
+
|
|
104
|
+
# Source: Squint default. Over a background that varies, the worst tenth of the
|
|
105
|
+
# text decides: one glyph in a word of ten. A title that half disappears is caught
|
|
106
|
+
# and a few stray pixels of a photo are not.
|
|
107
|
+
LOW_CONTRAST_WORST_PART_PERCENT = 10
|
|
108
|
+
|
|
109
|
+
# Source: Squint default. Ink runs from 0 to 255: a pixel at least half covered by
|
|
110
|
+
# a glyph is text, a fainter anti-aliased edge is background.
|
|
111
|
+
TEXT_INK_MIN = 128
|