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 ADDED
@@ -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)
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